Skip to content
8 changes: 5 additions & 3 deletions optional-skills/autonomous-ai-agents/honcho/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,10 @@ Controls **how often** dialectic and context calls happen.
| Key | Default | Description |
|-----|---------|-------------|
| `contextCadence` | `1` | Min turns between context API calls |
| `dialecticCadence` | `3` | Min turns between dialectic API calls |
| `dialecticCadence` | `2` | Min turns between dialectic API calls. Recommended 1–5 |
| `injectionFrequency` | `every-turn` | `every-turn` or `first-turn` for base context injection |

Higher cadence values reduce API calls and cost. `dialecticCadence: 3` (default) means the dialectic engine fires at most every 3rd turn.
Higher cadence values fire the dialectic LLM less often. `dialecticCadence: 2` means the engine fires every other turn. Setting it to `1` fires every turn.

### Depth (how many)

Expand Down Expand Up @@ -180,6 +180,8 @@ If `dialecticDepthLevels` is omitted, rounds use **proportional levels** derived

This keeps earlier passes cheap while using full depth on the final synthesis.

**Depth at session start.** The session-start prewarm runs the full configured `dialecticDepth` in the background before turn 1. A single-pass prewarm on a cold peer often returns thin output — multi-pass depth runs the audit/reconcile cycle before the user ever speaks. Turn 1 consumes the prewarm result directly; if prewarm hasn't landed in time, turn 1 falls back to a synchronous call with a bounded timeout.

### Level (how hard)

Controls the **intensity** of each dialectic reasoning round.
Expand Down Expand Up @@ -368,7 +370,7 @@ Config file: `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.jso
| `contextTokens` | uncapped | Max tokens for the combined base context injection (summary + representation + card). Opt-in cap — omit to leave uncapped, set to an integer to bound injection size. |
| `injectionFrequency` | `every-turn` | `every-turn` or `first-turn` |
| `contextCadence` | `1` | Min turns between context API calls |
| `dialecticCadence` | `3` | Min turns between dialectic LLM calls |
| `dialecticCadence` | `2` | Min turns between dialectic LLM calls (recommended 1–5) |

The `contextTokens` budget is enforced at injection time. If the session summary + representation + card exceed the budget, Honcho trims the summary first, then the representation, preserving the card. This prevents context blowup in long sessions.

Expand Down
305 changes: 252 additions & 53 deletions plugins/memory/honcho/__init__.py

Large diffs are not rendered by default.

31 changes: 27 additions & 4 deletions plugins/memory/honcho/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,17 +460,37 @@ def cmd_setup(args) -> None:
pass # keep current

# --- 7b. Dialectic cadence ---
current_dialectic = str(hermes_host.get("dialecticCadence") or cfg.get("dialecticCadence") or "3")
current_dialectic = str(hermes_host.get("dialecticCadence") or cfg.get("dialecticCadence") or "2")
print("\n Dialectic cadence:")
print(" How often Honcho rebuilds its user model (LLM call on Honcho backend).")
print(" 1 = every turn (aggressive), 3 = every 3 turns (recommended), 5+ = sparse.")
print(" 1 = every turn, 2 = every other turn, 3+ = sparser.")
print(" Recommended: 1-5.")
new_dialectic = _prompt("Dialectic cadence", default=current_dialectic)
try:
val = int(new_dialectic)
if val >= 1:
hermes_host["dialecticCadence"] = val
except (ValueError, TypeError):
hermes_host["dialecticCadence"] = 3
hermes_host["dialecticCadence"] = 2

# --- 7c. Dialectic reasoning level ---
current_reasoning = (
hermes_host.get("dialecticReasoningLevel")
or cfg.get("dialecticReasoningLevel")
or "low"
)
print("\n Dialectic reasoning level:")
print(" Depth Honcho uses when synthesizing user context on auto-injected calls.")
print(" minimal -- quick factual lookups")
print(" low -- straightforward questions (default)")
print(" medium -- multi-aspect synthesis")
print(" high -- complex behavioral patterns")
print(" max -- thorough audit-level analysis")
new_reasoning = _prompt("Reasoning level", default=current_reasoning)
if new_reasoning in ("minimal", "low", "medium", "high", "max"):
hermes_host["dialecticReasoningLevel"] = new_reasoning
else:
hermes_host["dialecticReasoningLevel"] = "low"

# --- 8. Session strategy ---
current_strat = hermes_host.get("sessionStrategy") or cfg.get("sessionStrategy", "per-session")
Expand Down Expand Up @@ -636,8 +656,11 @@ def cmd_status(args) -> None:
print(f" Recall mode: {hcfg.recall_mode}")
print(f" Context budget: {hcfg.context_tokens or '(uncapped)'} tokens")
raw = getattr(hcfg, "raw", None) or {}
dialectic_cadence = raw.get("dialecticCadence") or 3
dialectic_cadence = raw.get("dialecticCadence") or 1
print(f" Dialectic cad: every {dialectic_cadence} turn{'s' if dialectic_cadence != 1 else ''}")
reasoning_cap = raw.get("reasoningLevelCap") or hcfg.reasoning_level_cap
heuristic_on = "on" if hcfg.reasoning_heuristic else "off"
print(f" Reasoning: base={hcfg.dialectic_reasoning_level}, cap={reasoning_cap}, heuristic={heuristic_on}")
print(f" Observation: user(me={hcfg.user_observe_me},others={hcfg.user_observe_others}) ai(me={hcfg.ai_observe_me},others={hcfg.ai_observe_others})")
print(f" Write freq: {hcfg.write_frequency}")

Expand Down
15 changes: 15 additions & 0 deletions plugins/memory/honcho/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,11 @@ class HonchoClientConfig:
# matching dialectic_depth length. When None, uses proportional defaults
# derived from dialectic_reasoning_level.
dialectic_depth_levels: list[str] | None = None
# When true, the auto-injected dialectic scales reasoning level up on
# longer queries. See HonchoMemoryProvider for thresholds.
reasoning_heuristic: bool = True
# Ceiling for the heuristic-selected reasoning level.
reasoning_level_cap: str = "high"
# Honcho API limits — configurable for self-hosted instances
# Max chars per message sent via add_messages() (Honcho cloud: 25000)
message_max_chars: int = 25000
Expand Down Expand Up @@ -446,6 +451,16 @@ def from_global_config(
raw.get("dialecticDepthLevels"),
depth=_parse_dialectic_depth(host_block.get("dialecticDepth"), raw.get("dialecticDepth")),
),
reasoning_heuristic=_resolve_bool(
host_block.get("reasoningHeuristic"),
raw.get("reasoningHeuristic"),
default=True,
),
reasoning_level_cap=(
host_block.get("reasoningLevelCap")
or raw.get("reasoningLevelCap")
or "high"
),
message_max_chars=int(
host_block.get("messageMaxChars")
or raw.get("messageMaxChars")
Expand Down
55 changes: 13 additions & 42 deletions plugins/memory/honcho/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def __init__(
honcho: Honcho | None = None,
context_tokens: int | None = None,
config: Any | None = None,
runtime_user_peer_name: str | None = None,
):
"""
Initialize the session manager.
Expand All @@ -87,10 +88,12 @@ def __init__(
context_tokens: Max tokens for context() calls (None = Honcho default).
config: HonchoClientConfig from global config (provides peer_name, ai_peer,
write_frequency, observation, etc.).
runtime_user_peer_name: Gateway user identity for per-user memory scoping.
"""
self._honcho = honcho
self._context_tokens = context_tokens
self._config = config
self._runtime_user_peer_name = runtime_user_peer_name
self._cache: dict[str, HonchoSession] = {}
self._peers_cache: dict[str, Any] = {}
self._sessions_cache: dict[str, Any] = {}
Expand All @@ -100,9 +103,11 @@ def __init__(
self._write_frequency = write_frequency
self._turn_counter: int = 0

# Prefetch caches: session_key → last result (consumed once per turn)
# Prefetch cache: session_key → last context result (consumed once per turn).
# Dialectic results are cached on the plugin side (HonchoMemoryProvider
# ._prefetch_result) so session-start prewarm and turn-driven fires share
# one source of truth; see __init__.py _do_session_init for the prewarm.
self._context_cache: dict[str, dict] = {}
self._dialectic_cache: dict[str, str] = {}
self._prefetch_cache_lock = threading.Lock()
self._dialectic_reasoning_level: str = (
config.dialectic_reasoning_level if config else "low"
Expand Down Expand Up @@ -272,8 +277,10 @@ def get_or_create(self, key: str) -> HonchoSession:
logger.debug("Local session cache hit: %s", key)
return self._cache[key]

# Use peer names from global config when available
if self._config and self._config.peer_name:
# Gateway sessions should use the runtime user identity when available.
if self._runtime_user_peer_name:
user_peer_id = self._sanitize_id(self._runtime_user_peer_name)
elif self._config and self._config.peer_name:
user_peer_id = self._sanitize_id(self._config.peer_name)
else:
# Fallback: derive from session key
Expand Down Expand Up @@ -499,8 +506,8 @@ def dialectic_query(
Query Honcho's dialectic endpoint about a peer.

Runs an LLM on Honcho's backend against the target peer's full
representation. Higher latency than context() — call async via
prefetch_dialectic() to avoid blocking the response.
representation. Higher latency than context() — callers run this in
a background thread (see HonchoMemoryProvider) to avoid blocking.

Args:
session_key: The session key to query against.
Expand Down Expand Up @@ -555,42 +562,6 @@ def dialectic_query(
logger.warning("Honcho dialectic query failed: %s", e)
return ""

def prefetch_dialectic(self, session_key: str, query: str) -> None:
"""
Fire a dialectic_query in a background thread, caching the result.

Non-blocking. The result is available via pop_dialectic_result()
on the next call (typically the following turn). Reasoning level
is selected dynamically based on query complexity.

Args:
session_key: The session key to query against.
query: The user's current message, used as the query.
"""
def _run():
result = self.dialectic_query(session_key, query)
if result:
self.set_dialectic_result(session_key, result)

t = threading.Thread(target=_run, name="honcho-dialectic-prefetch", daemon=True)
t.start()

def set_dialectic_result(self, session_key: str, result: str) -> None:
"""Store a prefetched dialectic result in a thread-safe way."""
if not result:
return
with self._prefetch_cache_lock:
self._dialectic_cache[session_key] = result

def pop_dialectic_result(self, session_key: str) -> str:
"""
Return and clear the cached dialectic result for this session.

Returns empty string if no result is ready yet.
"""
with self._prefetch_cache_lock:
return self._dialectic_cache.pop(session_key, "")

def prefetch_context(self, session_key: str, user_message: str | None = None) -> None:
"""
Fire get_prefetch_context in a background thread, caching the result.
Expand Down
2 changes: 0 additions & 2 deletions tests/agent/test_memory_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -971,8 +971,6 @@ def test_queue_prefetch_respects_dialectic_cadence(self):
class FakeManager:
def prefetch_context(self, key, query=None):
pass
def prefetch_dialectic(self, key, query):
pass

p._manager = FakeManager()

Expand Down
65 changes: 56 additions & 9 deletions tests/agent/test_memory_user_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,34 +208,81 @@ def test_different_users_get_different_ids(self):


class TestHonchoUserIdScoping:
"""Verify Honcho plugin uses gateway user_id for peer_name when provided."""
"""Verify Honcho plugin keeps runtime user scoping separate from config peer_name."""

def test_gateway_user_id_overrides_peer_name(self):
"""When user_id is in kwargs and no explicit peer_name, user_id should be used."""
def test_gateway_user_id_is_passed_as_runtime_peer(self):
"""Gateway user_id should scope Honcho sessions without mutating config peer_name."""
from plugins.memory.honcho import HonchoMemoryProvider

provider = HonchoMemoryProvider()

# Create a mock config with NO explicit peer_name
mock_cfg = MagicMock()
mock_cfg.enabled = True
mock_cfg.api_key = "test-key"
mock_cfg.base_url = None
mock_cfg.peer_name = "" # No explicit peer_name — user_id should fill it
mock_cfg.recall_mode = "tools" # Use tools mode to defer session init
mock_cfg.peer_name = "static-user"
mock_cfg.recall_mode = "context"
mock_cfg.context_tokens = None
mock_cfg.raw = {}
mock_cfg.dialectic_depth = 1
mock_cfg.dialectic_depth_levels = None
mock_cfg.init_on_session_start = False
mock_cfg.ai_peer = "hermes"
mock_cfg.resolve_session_name.return_value = "test-sess"
mock_cfg.session_strategy = "shared"

with patch(
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
return_value=mock_cfg,
):
), patch(
"plugins.memory.honcho.client.get_honcho_client",
return_value=MagicMock(),
), patch(
"plugins.memory.honcho.session.HonchoSessionManager",
) as mock_manager_cls:
mock_manager = MagicMock()
mock_manager.get_or_create.return_value = MagicMock(messages=[])
mock_manager_cls.return_value = mock_manager
provider.initialize(
session_id="test-sess",
user_id="discord_user_789",
platform="discord",
)

# The config's peer_name should have been overridden with the user_id
assert mock_cfg.peer_name == "discord_user_789"
assert mock_cfg.peer_name == "static-user"
assert mock_manager_cls.call_args.kwargs["runtime_user_peer_name"] == "discord_user_789"

def test_session_manager_prefers_runtime_user_id_over_config_peer_name(self):
"""Session manager should isolate gateway users even when config peer_name is static."""
from plugins.memory.honcho.session import HonchoSessionManager

mock_cfg = MagicMock()
mock_cfg.peer_name = "static-user"
mock_cfg.ai_peer = "hermes"
mock_cfg.write_frequency = "sync"
mock_cfg.dialectic_reasoning_level = "low"
mock_cfg.dialectic_dynamic = True
mock_cfg.dialectic_max_chars = 600
mock_cfg.observation_mode = "directional"
mock_cfg.user_observe_me = True
mock_cfg.user_observe_others = True
mock_cfg.ai_observe_me = True
mock_cfg.ai_observe_others = True

manager = HonchoSessionManager(
honcho=MagicMock(),
config=mock_cfg,
runtime_user_peer_name="discord_user_789",
)

with patch.object(manager, "_get_or_create_peer", return_value=MagicMock()), patch.object(
manager,
"_get_or_create_honcho_session",
return_value=(MagicMock(), []),
):
session = manager.get_or_create("discord:channel-1")

assert session.user_peer_id == "discord_user_789"

def test_no_user_id_preserves_config_peer_name(self):
"""Without user_id, the config peer_name should be preserved."""
Expand Down
7 changes: 0 additions & 7 deletions tests/honcho_plugin/test_async_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -460,10 +460,3 @@ def test_set_and_pop_context_result(self):
assert mgr.pop_context_result("cli:test") == payload
assert mgr.pop_context_result("cli:test") == {}

def test_set_and_pop_dialectic_result(self):
mgr = _make_manager(write_frequency="turn")

mgr.set_dialectic_result("cli:test", "Resume with toolset cleanup")

assert mgr.pop_dialectic_result("cli:test") == "Resume with toolset cleanup"
assert mgr.pop_dialectic_result("cli:test") == ""
3 changes: 3 additions & 0 deletions tests/honcho_plugin/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ class FakeConfig:
write_frequency = "async"
session_strategy = "per-session"
context_tokens = 800
dialectic_reasoning_level = "low"
reasoning_level_cap = "high"
reasoning_heuristic = True

def resolve_session_name(self):
return "hermes"
Expand Down
Loading
Loading