mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
feat(honcho): context injection overhaul, 5-tool surface, cost safety, session isolation
Context Injection Overhaul: - Base layer: peer.context() (representation + card) cached with 5-minute TTL - Dialectic supplement: cadence-gated, cached until next refresh - Trivial prompt skip: short inputs/slash commands skip injection - New peer guard: dialectic skipped at session start when peer has no context - Targeted warm prompt for better dialectic quality Tool Surface (5 bidirectional tools): - honcho_profile: read or update peer card - honcho_search: semantic search over context - honcho_context: full session context (summary, representation, card, messages) - honcho_reasoning: synthesized answer, reasoning_level param - honcho_conclude: create or delete conclusions (PII removal) Cost Safety: - dialectic_cadence defaults to 3 (~66% fewer LLM calls) - context_tokens defaults to uncapped (cap opt-in via config/wizard) - on_turn_start hook wired up (fixes broken cadence/injection gating) Correctness: - Explicit target= on peer context/card fetches (fixes identity blur) - honcho_search perspective fix under directional observation - Timeout config plumbing - peerName precedence over gateway user_id - skip_memory on temp agents (orphan session prevention) - gateway_session_key for stable per-chat session continuity - initOnSessionStart for eager tools-mode init - get_session_context fallback respects peer param - mid -> medium in reasoning level validation ABC changes (minimal, honcho-only): - run_agent.py: gateway_session_key param + memory provider wiring (+5 lines) - gateway/run.py: skip_memory on 2 temp agents, gateway_session_key on main agent (+3 lines) - agent/memory_manager.py: sanitize regex for context tag variants (+9 lines)
This commit is contained in:
parent
95d11dfd8e
commit
11b4c9ecf9
16 changed files with 1283 additions and 331 deletions
|
|
@ -28,6 +28,7 @@ Usage in run_agent.py:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
|
@ -43,6 +44,14 @@ logger = logging.getLogger(__name__)
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FENCE_TAG_RE = re.compile(r'</?\s*memory-context\s*>', re.IGNORECASE)
|
||||
_INTERNAL_CONTEXT_RE = re.compile(
|
||||
r'<\s*(?:memory-context|supermemory-context|supermemory-containers)\s*>[\s\S]*?</\s*(?:memory-context|supermemory-context|supermemory-containers)\s*>',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_INTERNAL_NOTE_RE = re.compile(
|
||||
r'\[System note:\s*The following is recalled memory context,\s*NOT new user input\.\s*Treat as informational background data\.\]\s*',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def sanitize_context(text: str) -> str:
|
||||
|
|
|
|||
81
extraction-queue.txt
Normal file
81
extraction-queue.txt
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# Design Pattern Extraction Queue
|
||||
# Generated 2026-04-10
|
||||
# Total: ~74 sites
|
||||
|
||||
## Eri's List (30 - skip kellychong.ca, done)
|
||||
https://tahahossain.com/
|
||||
https://liam.cv/
|
||||
https://www.alicias.page/
|
||||
https://haleypark.design/
|
||||
https://eriks.design/
|
||||
https://www.trybehold.com/
|
||||
https://variant.ai/
|
||||
https://benji.org/
|
||||
https://marijanapav.com/stamps
|
||||
https://tokyo.floguo.com/
|
||||
https://www.cosmos.so/public-work
|
||||
https://ryo.lu/
|
||||
https://alistair.sh/
|
||||
https://tommytrinh.me/
|
||||
https://map.simonsarris.com/?utm_campaign=profile_chips
|
||||
https://www.limjungyoon.com/portfolio/simplecomputer
|
||||
https://graphicsf22.notion.site/reading-2f6157fd47778053bc3cd503d608cc81
|
||||
https://bradleyziffer.com/
|
||||
https://diana.lu/
|
||||
https://otherkind.studio/
|
||||
https://www.daybreak.studio/work
|
||||
https://www.zegzulka.com/
|
||||
https://jakub.kr/
|
||||
https://www.nostalgicsnacks.com/
|
||||
https://www.jw.works/
|
||||
https://www.maxpotze.com/
|
||||
https://www.rachelchen.tech/
|
||||
https://www.goabstract.com/
|
||||
https://timobecker.com/
|
||||
|
||||
## Notion Page Links (45)
|
||||
https://www.karststonepaper.com/
|
||||
https://www.thinkwithgoogle.com/feature/ml-fairness-for-marketers/
|
||||
https://brunoarizio.com/
|
||||
http://www.garysheng.com/
|
||||
https://www.oliverkiss.design/
|
||||
https://activetheory.net/
|
||||
https://bizar.ro/
|
||||
http://www.underforest.com/
|
||||
https://gavedikian.io/about/
|
||||
http://www.hemingwayapp.com/
|
||||
https://makestuffup.info/
|
||||
http://www.dennisadelmann.de/
|
||||
https://labs.jensimmons.com/2016/
|
||||
https://themessagetoukraine.obys.agency/
|
||||
https://www.felipe.design/
|
||||
https://emilkowal.ski/
|
||||
https://manuelmoreale.com/
|
||||
http://brianlovin.com/
|
||||
https://paco.me/
|
||||
https://rauno.me/
|
||||
https://flow.rest/
|
||||
https://grotesque.basement.studio/
|
||||
https://jonstuebe.com/notes
|
||||
https://leerob.io/
|
||||
https://zenorocha.com/
|
||||
https://www.ryanstephen.co/
|
||||
https://www.lindaeliasen.com/
|
||||
https://www.patrickaltair.com/
|
||||
https://www.c-ehrlich.dev/
|
||||
https://marcbouchenoire.com/
|
||||
https://www.wtw.dev/
|
||||
https://jm.sv/not-work
|
||||
https://twentyeight.studio/team
|
||||
https://linusrogge.com/
|
||||
https://archive.withcompound.com/
|
||||
https://www.nan.fyi/
|
||||
https://www.inkandswitch.com/
|
||||
https://www.sj.land/
|
||||
https://tomasmaillo.com/
|
||||
https://jonmilner.com/
|
||||
https://danmall.com/
|
||||
https://www.nilseller.com/
|
||||
https://designwithtech.com/
|
||||
https://www.aaraalto.com/
|
||||
https://www.antonstallboerger.com/
|
||||
|
|
@ -3442,6 +3442,7 @@ class GatewayRunner:
|
|||
model=_hyg_model,
|
||||
max_iterations=4,
|
||||
quiet_mode=True,
|
||||
skip_memory=True,
|
||||
enabled_toolsets=["memory"],
|
||||
session_id=session_entry.session_id,
|
||||
)
|
||||
|
|
@ -5901,6 +5902,7 @@ class GatewayRunner:
|
|||
model=model,
|
||||
max_iterations=4,
|
||||
quiet_mode=True,
|
||||
skip_memory=True,
|
||||
enabled_toolsets=["memory"],
|
||||
session_id=session_entry.session_id,
|
||||
)
|
||||
|
|
@ -8189,6 +8191,7 @@ class GatewayRunner:
|
|||
session_id=session_id,
|
||||
platform=platform_key,
|
||||
user_id=source.user_id,
|
||||
gateway_session_key=session_key,
|
||||
session_db=self._session_db,
|
||||
fallback_model=self._fallback_model,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ Settings changed in the [Honcho dashboard](https://app.honcho.dev) are synced ba
|
|||
|-----|------|---------|-------|-------------|
|
||||
| `contextTokens` | int | SDK default | root / host | Token budget for `context()` API calls. Also gates prefetch truncation (tokens x 4 chars) |
|
||||
| `dialecticReasoningLevel` | string | `"low"` | root / host | Base reasoning level for `peer.chat()`: `"minimal"`, `"low"`, `"medium"`, `"high"`, `"max"` |
|
||||
| `dialecticDynamic` | bool | `true` | root / host | Auto-bump reasoning based on query length: `<120` chars = base level, `120-400` = +1, `>400` = +2 (capped at `"high"`). Set `false` to always use `dialecticReasoningLevel` as-is |
|
||||
| `dialecticDynamic` | bool | `true` | root / host | When `true`, the model can override reasoning level per-call via the `honcho_reasoning` tool `reasoning_level` param (agentic). When `false`, always uses `dialecticReasoningLevel` and ignores model overrides |
|
||||
| `dialecticMaxChars` | int | `600` | root / host | Max chars of dialectic result injected into system prompt |
|
||||
| `dialecticMaxInputChars` | int | `10000` | root / host | Max chars for dialectic query input to `peer.chat()`. Honcho cloud limit: 10k |
|
||||
| `messageMaxChars` | int | `25000` | root / host | Max chars per message sent via `add_messages()`. Messages exceeding this are chunked with `[continued]` markers. Honcho cloud limit: 25k |
|
||||
|
|
@ -139,7 +139,7 @@ These are read from the root config object, not the host block. Must be set manu
|
|||
| `injectionFrequency` | string | `"every-turn"` | `"every-turn"` or `"first-turn"` (inject context only on turn 0) |
|
||||
| `contextCadence` | int | `1` | Minimum turns between `context()` API calls |
|
||||
| `dialecticCadence` | int | `1` | Minimum turns between `peer.chat()` API calls |
|
||||
| `reasoningLevelCap` | string | -- | Hard cap on auto-bumped reasoning: `"minimal"`, `"low"`, `"mid"`, `"high"` |
|
||||
| `reasoningLevelCap` | string | -- | Hard cap on reasoning level: `"minimal"`, `"low"`, `"medium"`, `"high"` |
|
||||
|
||||
### Hardcoded Limits (Not Configurable)
|
||||
|
||||
|
|
|
|||
|
|
@ -33,20 +33,33 @@ logger = logging.getLogger(__name__)
|
|||
PROFILE_SCHEMA = {
|
||||
"name": "honcho_profile",
|
||||
"description": (
|
||||
"Retrieve the user's peer card from Honcho — a curated list of key facts "
|
||||
"about them (name, role, preferences, communication style, patterns). "
|
||||
"Fast, no LLM reasoning, minimal cost. "
|
||||
"Use this at conversation start or when you need a quick factual snapshot."
|
||||
"Retrieve or update a peer card from Honcho — a curated list of key facts "
|
||||
"about that peer (name, role, preferences, communication style, patterns). "
|
||||
"Pass `card` to update; omit `card` to read."
|
||||
),
|
||||
"parameters": {"type": "object", "properties": {}, "required": []},
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"peer": {
|
||||
"type": "string",
|
||||
"description": "Peer to query. Built-in aliases: 'user' (default), 'ai'. Or pass any peer ID from this workspace.",
|
||||
},
|
||||
"card": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "New peer card as a list of fact strings. Omit to read the current card.",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
}
|
||||
|
||||
SEARCH_SCHEMA = {
|
||||
"name": "honcho_search",
|
||||
"description": (
|
||||
"Semantic search over Honcho's stored context about the user. "
|
||||
"Semantic search over Honcho's stored context about a peer. "
|
||||
"Returns raw excerpts ranked by relevance — no LLM synthesis. "
|
||||
"Cheaper and faster than honcho_context. "
|
||||
"Cheaper and faster than honcho_reasoning. "
|
||||
"Good when you want to find specific past facts and reason over them yourself."
|
||||
),
|
||||
"parameters": {
|
||||
|
|
@ -60,17 +73,23 @@ SEARCH_SCHEMA = {
|
|||
"type": "integer",
|
||||
"description": "Token budget for returned context (default 800, max 2000).",
|
||||
},
|
||||
"peer": {
|
||||
"type": "string",
|
||||
"description": "Peer to query. Built-in aliases: 'user' (default), 'ai'. Or pass any peer ID from this workspace.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
}
|
||||
|
||||
CONTEXT_SCHEMA = {
|
||||
"name": "honcho_context",
|
||||
REASONING_SCHEMA = {
|
||||
"name": "honcho_reasoning",
|
||||
"description": (
|
||||
"Ask Honcho a natural language question and get a synthesized answer. "
|
||||
"Uses Honcho's LLM (dialectic reasoning) — higher cost than honcho_profile or honcho_search. "
|
||||
"Can query about any peer: the user (default) or the AI assistant."
|
||||
"Can query about any peer via alias or explicit peer ID. "
|
||||
"Pass reasoning_level to control depth: minimal (fast/cheap), low (default), "
|
||||
"medium, high, max (deep/expensive). Omit for configured default."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
|
|
@ -79,37 +98,83 @@ CONTEXT_SCHEMA = {
|
|||
"type": "string",
|
||||
"description": "A natural language question.",
|
||||
},
|
||||
"reasoning_level": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Override the default reasoning depth. "
|
||||
"Omit to use the configured default (typically low). "
|
||||
"Guide:\n"
|
||||
"- minimal: quick factual lookups (name, role, simple preference)\n"
|
||||
"- low: straightforward questions with clear answers\n"
|
||||
"- medium: multi-aspect questions requiring synthesis across observations\n"
|
||||
"- high: complex behavioral patterns, contradictions, deep analysis\n"
|
||||
"- max: thorough audit-level analysis, leave no stone unturned"
|
||||
),
|
||||
"enum": ["minimal", "low", "medium", "high", "max"],
|
||||
},
|
||||
"peer": {
|
||||
"type": "string",
|
||||
"description": "Which peer to query about: 'user' (default) or 'ai'.",
|
||||
"description": "Peer to query. Built-in aliases: 'user' (default), 'ai'. Or pass any peer ID from this workspace.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
}
|
||||
|
||||
CONTEXT_SCHEMA = {
|
||||
"name": "honcho_context",
|
||||
"description": (
|
||||
"Retrieve full session context from Honcho — summary, peer representation, "
|
||||
"peer card, and recent messages. No LLM synthesis. "
|
||||
"Cheaper than honcho_reasoning. Use this to see what Honcho knows about "
|
||||
"the current conversation and the specified peer."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Optional focus query to filter context. Omit for full session context snapshot.",
|
||||
},
|
||||
"peer": {
|
||||
"type": "string",
|
||||
"description": "Peer to query. Built-in aliases: 'user' (default), 'ai'. Or pass any peer ID from this workspace.",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
},
|
||||
}
|
||||
|
||||
CONCLUDE_SCHEMA = {
|
||||
"name": "honcho_conclude",
|
||||
"description": (
|
||||
"Write a conclusion about the user back to Honcho's memory. "
|
||||
"Conclusions are persistent facts that build the user's profile. "
|
||||
"Use when the user states a preference, corrects you, or shares "
|
||||
"something to remember across sessions."
|
||||
"Write or delete a conclusion about a peer in Honcho's memory. "
|
||||
"Conclusions are persistent facts that build a peer's profile. "
|
||||
"Pass `conclusion` to create. Pass `delete_id` to remove a conclusion "
|
||||
"containing personal information — Honcho self-heals incorrect "
|
||||
"conclusions over time, so deletion is only needed for PII removal."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"conclusion": {
|
||||
"type": "string",
|
||||
"description": "A factual statement about the user to persist.",
|
||||
"description": "A factual statement to persist. Omit when using delete_id.",
|
||||
},
|
||||
"delete_id": {
|
||||
"type": "string",
|
||||
"description": "Conclusion ID to delete (for removing PII). Omit when creating.",
|
||||
},
|
||||
"peer": {
|
||||
"type": "string",
|
||||
"description": "Peer to query. Built-in aliases: 'user' (default), 'ai'. Or pass any peer ID from this workspace.",
|
||||
}
|
||||
},
|
||||
"required": ["conclusion"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
ALL_TOOL_SCHEMAS = [PROFILE_SCHEMA, SEARCH_SCHEMA, CONTEXT_SCHEMA, CONCLUDE_SCHEMA]
|
||||
ALL_TOOL_SCHEMAS = [PROFILE_SCHEMA, SEARCH_SCHEMA, REASONING_SCHEMA, CONTEXT_SCHEMA, CONCLUDE_SCHEMA]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -139,8 +204,8 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
self._turn_count = 0
|
||||
self._injection_frequency = "every-turn" # or "first-turn"
|
||||
self._context_cadence = 1 # minimum turns between context API calls
|
||||
self._dialectic_cadence = 1 # minimum turns between dialectic API calls
|
||||
self._reasoning_level_cap: Optional[str] = None # "minimal", "low", "mid", "high"
|
||||
self._dialectic_cadence = 3 # minimum turns between dialectic API calls
|
||||
self._reasoning_level_cap: Optional[str] = None # "minimal", "low", "medium", "high"
|
||||
self._last_context_turn = -999
|
||||
self._last_dialectic_turn = -999
|
||||
|
||||
|
|
@ -236,9 +301,9 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
raw = cfg.raw or {}
|
||||
self._injection_frequency = raw.get("injectionFrequency", "every-turn")
|
||||
self._context_cadence = int(raw.get("contextCadence", 1))
|
||||
self._dialectic_cadence = int(raw.get("dialecticCadence", 1))
|
||||
self._dialectic_cadence = int(raw.get("dialecticCadence", 3))
|
||||
cap = raw.get("reasoningLevelCap")
|
||||
if cap and cap in ("minimal", "low", "mid", "high"):
|
||||
if cap and cap in ("minimal", "low", "medium", "high"):
|
||||
self._reasoning_level_cap = cap
|
||||
except Exception as e:
|
||||
logger.debug("Honcho cost-awareness config parse error: %s", e)
|
||||
|
|
@ -251,9 +316,7 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
# ----- Port #1957: lazy session init for tools-only mode -----
|
||||
if self._recall_mode == "tools":
|
||||
if cfg.init_on_session_start:
|
||||
# Eager init: create session now so sync_turn() works from turn 1.
|
||||
# Does NOT enable auto-injection — prefetch() still returns empty.
|
||||
logger.debug("Honcho tools-only mode — eager session init (initOnSessionStart=true)")
|
||||
# Eager init even in tools mode (opt-in)
|
||||
self._do_session_init(cfg, session_id, **kwargs)
|
||||
return
|
||||
# Defer actual session creation until first tool call
|
||||
|
|
@ -287,8 +350,13 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
|
||||
# ----- B3: resolve_session_name -----
|
||||
session_title = kwargs.get("session_title")
|
||||
gateway_session_key = kwargs.get("gateway_session_key")
|
||||
self._session_key = (
|
||||
cfg.resolve_session_name(session_title=session_title, session_id=session_id)
|
||||
cfg.resolve_session_name(
|
||||
session_title=session_title,
|
||||
session_id=session_id,
|
||||
gateway_session_key=gateway_session_key,
|
||||
)
|
||||
or session_id
|
||||
or "hermes-default"
|
||||
)
|
||||
|
|
@ -299,12 +367,21 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
self._session_initialized = True
|
||||
|
||||
# ----- B6: Memory file migration (one-time, for new sessions) -----
|
||||
# Skip under per-session strategy: every Hermes run creates a fresh
|
||||
# Honcho session by design, so uploading MEMORY.md/USER.md/SOUL.md to
|
||||
# each one would flood the backend with short-lived duplicates instead
|
||||
# of performing a one-time migration.
|
||||
try:
|
||||
if not session.messages:
|
||||
if not session.messages and cfg.session_strategy != "per-session":
|
||||
from hermes_constants import get_hermes_home
|
||||
mem_dir = str(get_hermes_home() / "memories")
|
||||
self._manager.migrate_memory_files(self._session_key, mem_dir)
|
||||
logger.debug("Honcho memory file migration attempted for new session: %s", self._session_key)
|
||||
elif cfg.session_strategy == "per-session":
|
||||
logger.debug(
|
||||
"Honcho memory file migration skipped: per-session strategy creates a fresh session per run (%s)",
|
||||
self._session_key,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Honcho memory file migration skipped: %s", e)
|
||||
|
||||
|
|
@ -382,7 +459,7 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
return (
|
||||
"# Honcho Memory\n"
|
||||
"Active (tools-only mode). Use honcho_profile, honcho_search, "
|
||||
"honcho_context, and honcho_conclude tools to access user memory."
|
||||
"honcho_reasoning, honcho_context, and honcho_conclude tools to access user memory."
|
||||
)
|
||||
return ""
|
||||
|
||||
|
|
@ -412,7 +489,8 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
header = (
|
||||
"# Honcho Memory\n"
|
||||
"Active (tools-only mode). Use honcho_profile for a quick factual snapshot, "
|
||||
"honcho_search for raw excerpts, honcho_context for synthesized answers, "
|
||||
"honcho_search for raw excerpts, honcho_context for raw peer context, "
|
||||
"honcho_reasoning for synthesized answers, "
|
||||
"honcho_conclude to save facts about the user. "
|
||||
"No automatic context injection — you must use tools to access memory."
|
||||
)
|
||||
|
|
@ -421,7 +499,8 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
"# Honcho Memory\n"
|
||||
"Active (hybrid mode). Relevant context is auto-injected AND memory tools are available. "
|
||||
"Use honcho_profile for a quick factual snapshot, "
|
||||
"honcho_search for raw excerpts, honcho_context for synthesized answers, "
|
||||
"honcho_search for raw excerpts, honcho_context for raw peer context, "
|
||||
"honcho_reasoning for synthesized answers, "
|
||||
"honcho_conclude to save facts about the user."
|
||||
)
|
||||
|
||||
|
|
@ -458,7 +537,7 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
# ----- Port #3265: token budget enforcement -----
|
||||
result = self._truncate_to_budget(result)
|
||||
|
||||
return f"## Honcho Context\n{result}"
|
||||
return result
|
||||
|
||||
def _truncate_to_budget(self, text: str) -> str:
|
||||
"""Truncate text to fit within context_tokens budget if set."""
|
||||
|
|
@ -659,7 +738,14 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
|
||||
try:
|
||||
if tool_name == "honcho_profile":
|
||||
card = self._manager.get_peer_card(self._session_key)
|
||||
peer = args.get("peer", "user")
|
||||
card_update = args.get("card")
|
||||
if card_update:
|
||||
result = self._manager.set_peer_card(self._session_key, card_update, peer=peer)
|
||||
if result is None:
|
||||
return tool_error("Failed to update peer card.")
|
||||
return json.dumps({"result": f"Peer card updated ({len(result)} facts).", "card": result})
|
||||
card = self._manager.get_peer_card(self._session_key, peer=peer)
|
||||
if not card:
|
||||
return json.dumps({"result": "No profile facts available yet."})
|
||||
return json.dumps({"result": card})
|
||||
|
|
@ -669,30 +755,62 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
if not query:
|
||||
return tool_error("Missing required parameter: query")
|
||||
max_tokens = min(int(args.get("max_tokens", 800)), 2000)
|
||||
peer = args.get("peer", "user")
|
||||
result = self._manager.search_context(
|
||||
self._session_key, query, max_tokens=max_tokens
|
||||
self._session_key, query, max_tokens=max_tokens, peer=peer
|
||||
)
|
||||
if not result:
|
||||
return json.dumps({"result": "No relevant context found."})
|
||||
return json.dumps({"result": result})
|
||||
|
||||
elif tool_name == "honcho_context":
|
||||
elif tool_name == "honcho_reasoning":
|
||||
query = args.get("query", "")
|
||||
if not query:
|
||||
return tool_error("Missing required parameter: query")
|
||||
peer = args.get("peer", "user")
|
||||
reasoning_level = args.get("reasoning_level")
|
||||
result = self._manager.dialectic_query(
|
||||
self._session_key, query, peer=peer
|
||||
self._session_key, query,
|
||||
reasoning_level=reasoning_level,
|
||||
peer=peer,
|
||||
)
|
||||
return json.dumps({"result": result or "No result from Honcho."})
|
||||
|
||||
elif tool_name == "honcho_context":
|
||||
peer = args.get("peer", "user")
|
||||
ctx = self._manager.get_session_context(self._session_key, peer=peer)
|
||||
if not ctx:
|
||||
return json.dumps({"result": "No context available yet."})
|
||||
parts = []
|
||||
if ctx.get("summary"):
|
||||
parts.append(f"## Summary\n{ctx['summary']}")
|
||||
if ctx.get("representation"):
|
||||
parts.append(f"## Representation\n{ctx['representation']}")
|
||||
if ctx.get("card"):
|
||||
parts.append(f"## Card\n{ctx['card']}")
|
||||
if ctx.get("recent_messages"):
|
||||
msgs = ctx["recent_messages"]
|
||||
msg_str = "\n".join(
|
||||
f" [{m['role']}] {m['content'][:200]}"
|
||||
for m in msgs[-5:] # last 5 for brevity
|
||||
)
|
||||
parts.append(f"## Recent messages\n{msg_str}")
|
||||
return json.dumps({"result": "\n\n".join(parts) or "No context available."})
|
||||
|
||||
elif tool_name == "honcho_conclude":
|
||||
delete_id = args.get("delete_id")
|
||||
peer = args.get("peer", "user")
|
||||
if delete_id:
|
||||
ok = self._manager.delete_conclusion(self._session_key, delete_id, peer=peer)
|
||||
if ok:
|
||||
return json.dumps({"result": f"Conclusion {delete_id} deleted."})
|
||||
return tool_error(f"Failed to delete conclusion {delete_id}.")
|
||||
conclusion = args.get("conclusion", "")
|
||||
if not conclusion:
|
||||
return tool_error("Missing required parameter: conclusion")
|
||||
ok = self._manager.create_conclusion(self._session_key, conclusion)
|
||||
return tool_error("Missing required parameter: conclusion or delete_id")
|
||||
ok = self._manager.create_conclusion(self._session_key, conclusion, peer=peer)
|
||||
if ok:
|
||||
return json.dumps({"result": f"Conclusion saved: {conclusion}"})
|
||||
return json.dumps({"result": f"Conclusion saved for {peer}: {conclusion}"})
|
||||
return tool_error("Failed to save conclusion.")
|
||||
|
||||
return tool_error(f"Unknown tool: {tool_name}")
|
||||
|
|
|
|||
|
|
@ -440,11 +440,43 @@ def cmd_setup(args) -> None:
|
|||
if new_recall in ("hybrid", "context", "tools"):
|
||||
hermes_host["recallMode"] = new_recall
|
||||
|
||||
# --- 7. Session strategy ---
|
||||
current_strat = hermes_host.get("sessionStrategy") or cfg.get("sessionStrategy", "per-directory")
|
||||
# --- 7. Context token budget ---
|
||||
current_ctx_tokens = hermes_host.get("contextTokens") or cfg.get("contextTokens")
|
||||
current_display = str(current_ctx_tokens) if current_ctx_tokens else "uncapped"
|
||||
print("\n Context injection per turn (hybrid/context recall modes only):")
|
||||
print(" uncapped -- no limit (default)")
|
||||
print(" N -- token limit per turn (e.g. 1200)")
|
||||
new_ctx_tokens = _prompt("Context tokens", default=current_display)
|
||||
if new_ctx_tokens.strip().lower() in ("none", "uncapped", "no limit"):
|
||||
hermes_host.pop("contextTokens", None)
|
||||
elif new_ctx_tokens.strip() == "":
|
||||
pass # keep current
|
||||
else:
|
||||
try:
|
||||
val = int(new_ctx_tokens)
|
||||
if val >= 0:
|
||||
hermes_host["contextTokens"] = val
|
||||
except (ValueError, TypeError):
|
||||
pass # keep current
|
||||
|
||||
# --- 7b. Dialectic cadence ---
|
||||
current_dialectic = str(hermes_host.get("dialecticCadence") or cfg.get("dialecticCadence") or "3")
|
||||
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.")
|
||||
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
|
||||
|
||||
# --- 8. Session strategy ---
|
||||
current_strat = hermes_host.get("sessionStrategy") or cfg.get("sessionStrategy", "per-session")
|
||||
print("\n Session strategy:")
|
||||
print(" per-directory -- one session per working directory (default)")
|
||||
print(" per-session -- new Honcho session each run")
|
||||
print(" per-session -- each run starts clean, Honcho injects context automatically")
|
||||
print(" per-directory -- reuses session per dir, prior context auto-injected each run")
|
||||
print(" per-repo -- one session per git repository")
|
||||
print(" global -- single session across all directories")
|
||||
new_strat = _prompt("Session strategy", default=current_strat)
|
||||
|
|
@ -490,10 +522,11 @@ def cmd_setup(args) -> None:
|
|||
print(f" Recall: {hcfg.recall_mode}")
|
||||
print(f" Sessions: {hcfg.session_strategy}")
|
||||
print("\n Honcho tools available in chat:")
|
||||
print(" honcho_context -- ask Honcho about the user (LLM-synthesized)")
|
||||
print(" honcho_search -- semantic search over history (no LLM)")
|
||||
print(" honcho_profile -- peer card, key facts (no LLM)")
|
||||
print(" honcho_conclude -- persist a user fact to memory (no LLM)")
|
||||
print(" honcho_context -- session context: summary, representation, card, messages")
|
||||
print(" honcho_search -- semantic search over history")
|
||||
print(" honcho_profile -- peer card, key facts")
|
||||
print(" honcho_reasoning -- ask Honcho a question, synthesized answer")
|
||||
print(" honcho_conclude -- persist a user fact to memory")
|
||||
print("\n Other commands:")
|
||||
print(" hermes honcho status -- show full config")
|
||||
print(" hermes honcho mode -- change recall/observation mode")
|
||||
|
|
@ -585,13 +618,26 @@ def cmd_status(args) -> None:
|
|||
print(f" Enabled: {hcfg.enabled}")
|
||||
print(f" API key: {masked}")
|
||||
print(f" Workspace: {hcfg.workspace_id}")
|
||||
print(f" Config path: {active_path}")
|
||||
|
||||
# Config paths — show where config was read from and where writes go
|
||||
global_path = Path.home() / ".honcho" / "config.json"
|
||||
print(f" Config: {active_path}")
|
||||
if write_path != active_path:
|
||||
print(f" Write path: {write_path} (instance-local)")
|
||||
print(f" Write to: {write_path} (profile-local)")
|
||||
if active_path == global_path:
|
||||
print(f" Fallback: (none — using global ~/.honcho/config.json)")
|
||||
elif global_path.exists():
|
||||
print(f" Fallback: {global_path} (exists, cross-app interop)")
|
||||
|
||||
print(f" AI peer: {hcfg.ai_peer}")
|
||||
print(f" User peer: {hcfg.peer_name or 'not set'}")
|
||||
print(f" Session key: {hcfg.resolve_session_name()}")
|
||||
print(f" Session strat: {hcfg.session_strategy}")
|
||||
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
|
||||
print(f" Dialectic cad: every {dialectic_cadence} turn{'s' if dialectic_cadence != 1 else ''}")
|
||||
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}")
|
||||
|
||||
|
|
@ -599,8 +645,8 @@ def cmd_status(args) -> None:
|
|||
print("\n Connection... ", end="", flush=True)
|
||||
try:
|
||||
client = get_honcho_client(hcfg)
|
||||
print("OK")
|
||||
_show_peer_cards(hcfg, client)
|
||||
print("OK")
|
||||
except Exception as e:
|
||||
print(f"FAILED ({e})\n")
|
||||
else:
|
||||
|
|
@ -824,6 +870,41 @@ def cmd_mode(args) -> None:
|
|||
print(f" {label}Recall mode -> {mode_arg} ({MODES[mode_arg]})\n")
|
||||
|
||||
|
||||
def cmd_strategy(args) -> None:
|
||||
"""Show or set the session strategy."""
|
||||
STRATEGIES = {
|
||||
"per-session": "each run starts clean, Honcho injects context automatically",
|
||||
"per-directory": "reuses session per dir, prior context auto-injected each run",
|
||||
"per-repo": "one session per git repository",
|
||||
"global": "single session across all directories",
|
||||
}
|
||||
cfg = _read_config()
|
||||
strat_arg = getattr(args, "strategy", None)
|
||||
|
||||
if strat_arg is None:
|
||||
current = (
|
||||
(cfg.get("hosts") or {}).get(_host_key(), {}).get("sessionStrategy")
|
||||
or cfg.get("sessionStrategy")
|
||||
or "per-session"
|
||||
)
|
||||
print("\nHoncho session strategy\n" + "─" * 40)
|
||||
for s, desc in STRATEGIES.items():
|
||||
marker = " <-" if s == current else ""
|
||||
print(f" {s:<15} {desc}{marker}")
|
||||
print(f"\n Set with: hermes honcho strategy [per-session|per-directory|per-repo|global]\n")
|
||||
return
|
||||
|
||||
if strat_arg not in STRATEGIES:
|
||||
print(f" Invalid strategy '{strat_arg}'. Options: {', '.join(STRATEGIES)}\n")
|
||||
return
|
||||
|
||||
host = _host_key()
|
||||
label = f"[{host}] " if host != "hermes" else ""
|
||||
cfg.setdefault("hosts", {}).setdefault(host, {})["sessionStrategy"] = strat_arg
|
||||
_write_config(cfg)
|
||||
print(f" {label}Session strategy -> {strat_arg} ({STRATEGIES[strat_arg]})\n")
|
||||
|
||||
|
||||
def cmd_tokens(args) -> None:
|
||||
"""Show or set token budget settings."""
|
||||
cfg = _read_config()
|
||||
|
|
@ -1143,10 +1224,11 @@ def cmd_migrate(args) -> None:
|
|||
print(" automatically. Files become the seed, not the live store.")
|
||||
print()
|
||||
print(" Honcho tools (available to the agent during conversation)")
|
||||
print(" honcho_context — ask Honcho a question, get a synthesized answer (LLM)")
|
||||
print(" honcho_search — semantic search over stored context (no LLM)")
|
||||
print(" honcho_profile — fast peer card snapshot (no LLM)")
|
||||
print(" honcho_conclude — write a conclusion/fact back to memory (no LLM)")
|
||||
print(" honcho_context — session context: summary, representation, card, messages")
|
||||
print(" honcho_search — semantic search over stored context")
|
||||
print(" honcho_profile — fast peer card snapshot")
|
||||
print(" honcho_reasoning — ask Honcho a question, synthesized answer")
|
||||
print(" honcho_conclude — write a conclusion/fact back to memory")
|
||||
print()
|
||||
print(" Session naming")
|
||||
print(" OpenClaw: no persistent session concept — files are global.")
|
||||
|
|
@ -1197,6 +1279,8 @@ def honcho_command(args) -> None:
|
|||
cmd_peer(args)
|
||||
elif sub == "mode":
|
||||
cmd_mode(args)
|
||||
elif sub == "strategy":
|
||||
cmd_strategy(args)
|
||||
elif sub == "tokens":
|
||||
cmd_tokens(args)
|
||||
elif sub == "identity":
|
||||
|
|
@ -1211,7 +1295,7 @@ def honcho_command(args) -> None:
|
|||
cmd_sync(args)
|
||||
else:
|
||||
print(f" Unknown honcho command: {sub}")
|
||||
print(" Available: status, sessions, map, peer, mode, tokens, identity, migrate, enable, disable, sync\n")
|
||||
print(" Available: status, sessions, map, peer, mode, strategy, tokens, identity, migrate, enable, disable, sync\n")
|
||||
|
||||
|
||||
def register_cli(subparser) -> None:
|
||||
|
|
@ -1270,6 +1354,15 @@ def register_cli(subparser) -> None:
|
|||
help="Recall mode to set (hybrid/context/tools). Omit to show current.",
|
||||
)
|
||||
|
||||
strategy_parser = subs.add_parser(
|
||||
"strategy", help="Show or set session strategy (per-session/per-directory/per-repo/global)",
|
||||
)
|
||||
strategy_parser.add_argument(
|
||||
"strategy", nargs="?", metavar="STRATEGY",
|
||||
choices=("per-session", "per-directory", "per-repo", "global"),
|
||||
help="Session strategy to set. Omit to show current.",
|
||||
)
|
||||
|
||||
tokens_parser = subs.add_parser(
|
||||
"tokens", help="Show or set token budget for context and dialectic",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -94,6 +94,35 @@ def _resolve_bool(host_val, root_val, *, default: bool) -> bool:
|
|||
return default
|
||||
|
||||
|
||||
def _parse_context_tokens(host_val, root_val) -> int | None:
|
||||
"""Parse contextTokens: host wins, then root, then None (uncapped)."""
|
||||
for val in (host_val, root_val):
|
||||
if val is not None:
|
||||
try:
|
||||
return int(val)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_optional_float(*values: Any) -> float | None:
|
||||
"""Return the first non-empty value coerced to a positive float."""
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
continue
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if parsed > 0:
|
||||
return parsed
|
||||
return None
|
||||
|
||||
|
||||
_VALID_OBSERVATION_MODES = {"unified", "directional"}
|
||||
_OBSERVATION_MODE_ALIASES = {"shared": "unified", "separate": "directional", "cross": "directional"}
|
||||
|
||||
|
|
@ -159,6 +188,8 @@ class HonchoClientConfig:
|
|||
environment: str = "production"
|
||||
# Optional base URL for self-hosted Honcho (overrides environment mapping)
|
||||
base_url: str | None = None
|
||||
# Optional request timeout in seconds for Honcho SDK HTTP calls
|
||||
timeout: float | None = None
|
||||
# Identity
|
||||
peer_name: str | None = None
|
||||
ai_peer: str = "hermes"
|
||||
|
|
@ -168,14 +199,14 @@ class HonchoClientConfig:
|
|||
# Write frequency: "async" (background thread), "turn" (sync per turn),
|
||||
# "session" (flush on session end), or int (every N turns)
|
||||
write_frequency: str | int = "async"
|
||||
# Prefetch budget
|
||||
# Prefetch budget (None = no cap; set to an integer to bound auto-injected context)
|
||||
context_tokens: int | None = None
|
||||
# Dialectic (peer.chat) settings
|
||||
# reasoning_level: "minimal" | "low" | "medium" | "high" | "max"
|
||||
dialectic_reasoning_level: str = "low"
|
||||
# dynamic: auto-bump reasoning level based on query length
|
||||
# true — low->medium (120+ chars), low->high (400+ chars), capped at "high"
|
||||
# false — always use dialecticReasoningLevel as-is
|
||||
# When true, the model can override reasoning_level per-call via the
|
||||
# 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
|
||||
dialectic_max_chars: int = 600
|
||||
|
|
@ -189,10 +220,8 @@ class HonchoClientConfig:
|
|||
# "context" — auto-injected context only, Honcho tools removed
|
||||
# "tools" — Honcho tools only, no auto-injected context
|
||||
recall_mode: str = "hybrid"
|
||||
# When True and recallMode is "tools", create the Honcho session eagerly
|
||||
# during initialize() instead of deferring to the first tool call.
|
||||
# This ensures sync_turn() can write from the very first turn.
|
||||
# Does NOT enable automatic context injection — only changes init timing.
|
||||
# Eager init in tools mode — when true, initializes session during
|
||||
# initialize() instead of deferring to first tool call
|
||||
init_on_session_start: bool = False
|
||||
# Observation mode: legacy string shorthand ("directional" or "unified").
|
||||
# Kept for backward compat; granular per-peer booleans below are preferred.
|
||||
|
|
@ -224,12 +253,14 @@ class HonchoClientConfig:
|
|||
resolved_host = host or resolve_active_host()
|
||||
api_key = os.environ.get("HONCHO_API_KEY")
|
||||
base_url = os.environ.get("HONCHO_BASE_URL", "").strip() or None
|
||||
timeout = _resolve_optional_float(os.environ.get("HONCHO_TIMEOUT"))
|
||||
return cls(
|
||||
host=resolved_host,
|
||||
workspace_id=workspace_id,
|
||||
api_key=api_key,
|
||||
environment=os.environ.get("HONCHO_ENVIRONMENT", "production"),
|
||||
base_url=base_url,
|
||||
timeout=timeout,
|
||||
ai_peer=resolved_host,
|
||||
enabled=bool(api_key or base_url),
|
||||
)
|
||||
|
|
@ -290,6 +321,11 @@ class HonchoClientConfig:
|
|||
or os.environ.get("HONCHO_BASE_URL", "").strip()
|
||||
or None
|
||||
)
|
||||
timeout = _resolve_optional_float(
|
||||
raw.get("timeout"),
|
||||
raw.get("requestTimeout"),
|
||||
os.environ.get("HONCHO_TIMEOUT"),
|
||||
)
|
||||
|
||||
# Auto-enable when API key or base_url is present (unless explicitly disabled)
|
||||
# Host-level enabled wins, then root-level, then auto-enable if key/url exists.
|
||||
|
|
@ -335,17 +371,22 @@ class HonchoClientConfig:
|
|||
api_key=api_key,
|
||||
environment=environment,
|
||||
base_url=base_url,
|
||||
timeout=timeout,
|
||||
peer_name=host_block.get("peerName") or raw.get("peerName"),
|
||||
ai_peer=ai_peer,
|
||||
enabled=enabled,
|
||||
save_messages=save_messages,
|
||||
write_frequency=write_frequency,
|
||||
context_tokens=host_block.get("contextTokens") or raw.get("contextTokens"),
|
||||
context_tokens=_parse_context_tokens(
|
||||
host_block.get("contextTokens"),
|
||||
raw.get("contextTokens"),
|
||||
),
|
||||
dialectic_reasoning_level=(
|
||||
host_block.get("dialecticReasoningLevel")
|
||||
or raw.get("dialecticReasoningLevel")
|
||||
or "low"
|
||||
),
|
||||
|
||||
dialectic_dynamic=_resolve_bool(
|
||||
host_block.get("dialecticDynamic"),
|
||||
raw.get("dialecticDynamic"),
|
||||
|
|
@ -422,16 +463,18 @@ class HonchoClientConfig:
|
|||
cwd: str | None = None,
|
||||
session_title: str | None = None,
|
||||
session_id: str | None = None,
|
||||
gateway_session_key: str | None = None,
|
||||
) -> str | None:
|
||||
"""Resolve Honcho session name.
|
||||
|
||||
Resolution order:
|
||||
1. Manual directory override from sessions map
|
||||
2. Hermes session title (from /title command)
|
||||
3. per-session strategy — Hermes session_id ({timestamp}_{hex})
|
||||
4. per-repo strategy — git repo root directory name
|
||||
5. per-directory strategy — directory basename
|
||||
6. global strategy — workspace name
|
||||
3. Gateway session key (stable per-chat identifier from gateway platforms)
|
||||
4. per-session strategy — Hermes session_id ({timestamp}_{hex})
|
||||
5. per-repo strategy — git repo root directory name
|
||||
6. per-directory strategy — directory basename
|
||||
7. global strategy — workspace name
|
||||
"""
|
||||
import re
|
||||
|
||||
|
|
@ -451,6 +494,16 @@ class HonchoClientConfig:
|
|||
return f"{self.peer_name}-{sanitized}"
|
||||
return sanitized
|
||||
|
||||
# Gateway session key: stable per-chat identifier passed by the gateway
|
||||
# (e.g. "agent:main:telegram:dm:8439114563"). Sanitize colons to hyphens
|
||||
# for Honcho session ID compatibility. This takes priority over strategy-
|
||||
# based resolution because gateway platforms need per-chat isolation that
|
||||
# cwd-based strategies cannot provide.
|
||||
if gateway_session_key:
|
||||
sanitized = re.sub(r'[^a-zA-Z0-9_-]', '-', gateway_session_key).strip('-')
|
||||
if sanitized:
|
||||
return sanitized
|
||||
|
||||
# per-session: inherit Hermes session_id (new Honcho session each run)
|
||||
if self.session_strategy == "per-session" and session_id:
|
||||
if self.session_peer_prefix and self.peer_name:
|
||||
|
|
@ -512,13 +565,20 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
|||
# mapping, enabling remote self-hosted Honcho deployments without
|
||||
# requiring the server to live on localhost.
|
||||
resolved_base_url = config.base_url
|
||||
if not resolved_base_url:
|
||||
resolved_timeout = config.timeout
|
||||
if not resolved_base_url or resolved_timeout is None:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
hermes_cfg = load_config()
|
||||
honcho_cfg = hermes_cfg.get("honcho", {})
|
||||
if isinstance(honcho_cfg, dict):
|
||||
resolved_base_url = honcho_cfg.get("base_url", "").strip() or None
|
||||
if not resolved_base_url:
|
||||
resolved_base_url = honcho_cfg.get("base_url", "").strip() or None
|
||||
if resolved_timeout is None:
|
||||
resolved_timeout = _resolve_optional_float(
|
||||
honcho_cfg.get("timeout"),
|
||||
honcho_cfg.get("request_timeout"),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -553,6 +613,8 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
|||
}
|
||||
if resolved_base_url:
|
||||
kwargs["base_url"] = resolved_base_url
|
||||
if resolved_timeout is not None:
|
||||
kwargs["timeout"] = resolved_timeout
|
||||
|
||||
_honcho_client = Honcho(**kwargs)
|
||||
|
||||
|
|
|
|||
|
|
@ -486,36 +486,9 @@ class HonchoSessionManager:
|
|||
|
||||
_REASONING_LEVELS = ("minimal", "low", "medium", "high", "max")
|
||||
|
||||
def _dynamic_reasoning_level(self, query: str) -> str:
|
||||
"""
|
||||
Pick a reasoning level for a dialectic query.
|
||||
|
||||
When dialecticDynamic is true (default), auto-bumps based on query
|
||||
length so Honcho applies more inference where it matters:
|
||||
|
||||
< 120 chars -> configured default (typically "low")
|
||||
120-400 chars -> +1 level above default (cap at "high")
|
||||
> 400 chars -> +2 levels above default (cap at "high")
|
||||
|
||||
"max" is never selected automatically -- reserve it for explicit config.
|
||||
|
||||
When dialecticDynamic is false, always returns the configured level.
|
||||
"""
|
||||
if not self._dialectic_dynamic:
|
||||
return self._dialectic_reasoning_level
|
||||
|
||||
levels = self._REASONING_LEVELS
|
||||
default_idx = levels.index(self._dialectic_reasoning_level) if self._dialectic_reasoning_level in levels else 1
|
||||
n = len(query)
|
||||
if n < 120:
|
||||
bump = 0
|
||||
elif n < 400:
|
||||
bump = 1
|
||||
else:
|
||||
bump = 2
|
||||
# Cap at "high" (index 3) for auto-selection
|
||||
idx = min(default_idx + bump, 3)
|
||||
return levels[idx]
|
||||
def _default_reasoning_level(self) -> str:
|
||||
"""Return the configured default reasoning level."""
|
||||
return self._dialectic_reasoning_level
|
||||
|
||||
def dialectic_query(
|
||||
self, session_key: str, query: str,
|
||||
|
|
@ -532,8 +505,9 @@ class HonchoSessionManager:
|
|||
Args:
|
||||
session_key: The session key to query against.
|
||||
query: Natural language question.
|
||||
reasoning_level: Override the config default. If None, uses
|
||||
_dynamic_reasoning_level(query).
|
||||
reasoning_level: Override the configured default (dialecticReasoningLevel).
|
||||
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".
|
||||
|
||||
Returns:
|
||||
|
|
@ -543,29 +517,34 @@ class HonchoSessionManager:
|
|||
if not session:
|
||||
return ""
|
||||
|
||||
target_peer_id = self._resolve_peer_id(session, peer)
|
||||
if target_peer_id is None:
|
||||
return ""
|
||||
|
||||
# Guard: truncate query to Honcho's dialectic input limit
|
||||
if len(query) > self._dialectic_max_input_chars:
|
||||
query = query[:self._dialectic_max_input_chars].rsplit(" ", 1)[0]
|
||||
|
||||
level = reasoning_level or self._dynamic_reasoning_level(query)
|
||||
if self._dialectic_dynamic and reasoning_level:
|
||||
level = reasoning_level
|
||||
else:
|
||||
level = self._default_reasoning_level()
|
||||
|
||||
try:
|
||||
if self._ai_observe_others:
|
||||
# AI peer can observe user — use cross-observation routing
|
||||
if peer == "ai":
|
||||
ai_peer_obj = self._get_or_create_peer(session.assistant_peer_id)
|
||||
# AI peer can observe other peers — use assistant as observer.
|
||||
ai_peer_obj = self._get_or_create_peer(session.assistant_peer_id)
|
||||
if target_peer_id == session.assistant_peer_id:
|
||||
result = ai_peer_obj.chat(query, reasoning_level=level) or ""
|
||||
else:
|
||||
ai_peer_obj = self._get_or_create_peer(session.assistant_peer_id)
|
||||
result = ai_peer_obj.chat(
|
||||
query,
|
||||
target=session.user_peer_id,
|
||||
target=target_peer_id,
|
||||
reasoning_level=level,
|
||||
) or ""
|
||||
else:
|
||||
# AI can't observe others — each peer queries self
|
||||
peer_id = session.assistant_peer_id if peer == "ai" else session.user_peer_id
|
||||
target_peer = self._get_or_create_peer(peer_id)
|
||||
# Without cross-observation, each peer queries its own context.
|
||||
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
|
||||
|
|
@ -666,7 +645,7 @@ class HonchoSessionManager:
|
|||
|
||||
result: dict[str, str] = {}
|
||||
try:
|
||||
user_ctx = self._fetch_peer_context(session.user_peer_id)
|
||||
user_ctx = self._fetch_peer_context(session.user_peer_id, target=session.user_peer_id)
|
||||
result["representation"] = user_ctx["representation"]
|
||||
result["card"] = "\n".join(user_ctx["card"])
|
||||
except Exception as e:
|
||||
|
|
@ -674,7 +653,7 @@ class HonchoSessionManager:
|
|||
|
||||
# Also fetch AI peer's own representation so Hermes knows itself.
|
||||
try:
|
||||
ai_ctx = self._fetch_peer_context(session.assistant_peer_id)
|
||||
ai_ctx = self._fetch_peer_context(session.assistant_peer_id, target=session.assistant_peer_id)
|
||||
result["ai_representation"] = ai_ctx["representation"]
|
||||
result["ai_card"] = "\n".join(ai_ctx["card"])
|
||||
except Exception as e:
|
||||
|
|
@ -862,7 +841,7 @@ class HonchoSessionManager:
|
|||
return [str(item) for item in card if item]
|
||||
return [str(card)]
|
||||
|
||||
def _fetch_peer_card(self, peer_id: str) -> list[str]:
|
||||
def _fetch_peer_card(self, peer_id: str, *, target: str | None = None) -> list[str]:
|
||||
"""Fetch a peer card directly from the peer object.
|
||||
|
||||
This avoids relying on session.context(), which can return an empty
|
||||
|
|
@ -872,22 +851,33 @@ class HonchoSessionManager:
|
|||
peer = self._get_or_create_peer(peer_id)
|
||||
getter = getattr(peer, "get_card", None)
|
||||
if callable(getter):
|
||||
return self._normalize_card(getter())
|
||||
return self._normalize_card(getter(target=target) if target is not None else getter())
|
||||
|
||||
legacy_getter = getattr(peer, "card", None)
|
||||
if callable(legacy_getter):
|
||||
return self._normalize_card(legacy_getter())
|
||||
return self._normalize_card(legacy_getter(target=target) if target is not None else legacy_getter())
|
||||
|
||||
return []
|
||||
|
||||
def _fetch_peer_context(self, peer_id: str, search_query: str | None = None) -> dict[str, Any]:
|
||||
def _fetch_peer_context(
|
||||
self,
|
||||
peer_id: str,
|
||||
search_query: str | None = None,
|
||||
*,
|
||||
target: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch representation + peer card directly from a peer object."""
|
||||
peer = self._get_or_create_peer(peer_id)
|
||||
representation = ""
|
||||
card: list[str] = []
|
||||
|
||||
try:
|
||||
ctx = peer.context(search_query=search_query) if search_query else peer.context()
|
||||
context_kwargs: dict[str, Any] = {}
|
||||
if target is not None:
|
||||
context_kwargs["target"] = target
|
||||
if search_query is not None:
|
||||
context_kwargs["search_query"] = search_query
|
||||
ctx = peer.context(**context_kwargs) if context_kwargs else peer.context()
|
||||
representation = (
|
||||
getattr(ctx, "representation", None)
|
||||
or getattr(ctx, "peer_representation", None)
|
||||
|
|
@ -899,24 +889,106 @@ class HonchoSessionManager:
|
|||
|
||||
if not representation:
|
||||
try:
|
||||
representation = peer.representation() or ""
|
||||
representation = (
|
||||
peer.representation(target=target) if target is not None else peer.representation()
|
||||
) or ""
|
||||
except Exception as e:
|
||||
logger.debug("Direct peer.representation() failed for '%s': %s", peer_id, e)
|
||||
|
||||
if not card:
|
||||
try:
|
||||
card = self._fetch_peer_card(peer_id)
|
||||
card = self._fetch_peer_card(peer_id, target=target)
|
||||
except Exception as e:
|
||||
logger.debug("Direct peer card fetch failed for '%s': %s", peer_id, e)
|
||||
|
||||
return {"representation": representation, "card": card}
|
||||
|
||||
def get_peer_card(self, session_key: str) -> list[str]:
|
||||
def get_session_context(self, session_key: str, peer: str = "user") -> dict[str, Any]:
|
||||
"""Fetch full session context from Honcho including summary.
|
||||
|
||||
Uses the session-level context() API which returns summary,
|
||||
peer_representation, peer_card, and messages.
|
||||
"""
|
||||
Fetch the user peer's card — a curated list of key facts.
|
||||
session = self._cache.get(session_key)
|
||||
if not session:
|
||||
return {}
|
||||
|
||||
honcho_session = self._sessions_cache.get(session.honcho_session_id)
|
||||
if not honcho_session:
|
||||
# Fall back to peer-level context
|
||||
return self._fetch_peer_context(session.user_peer_id, target=session.user_peer_id)
|
||||
|
||||
try:
|
||||
peer_id = self._resolve_peer_id(session, peer)
|
||||
ctx = honcho_session.context(
|
||||
summary=True,
|
||||
peer_target=peer_id,
|
||||
peer_perspective=session.user_peer_id if peer == "user" else session.assistant_peer_id,
|
||||
)
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
# Summary
|
||||
if ctx.summary:
|
||||
result["summary"] = ctx.summary.content
|
||||
|
||||
# Peer representation and card
|
||||
if ctx.peer_representation:
|
||||
result["representation"] = ctx.peer_representation
|
||||
if ctx.peer_card:
|
||||
result["card"] = "\n".join(ctx.peer_card)
|
||||
|
||||
# Messages (last N for context)
|
||||
if ctx.messages:
|
||||
recent = ctx.messages[-10:] # last 10 messages
|
||||
result["recent_messages"] = [
|
||||
{"role": m.role, "content": m.content[:500]}
|
||||
for m in recent
|
||||
]
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning("Session context fetch failed: %s", e)
|
||||
return {}
|
||||
|
||||
def _resolve_peer_id(self, session: HonchoSession, peer: str | None) -> str | None:
|
||||
"""Resolve a peer alias or explicit peer ID to a concrete Honcho peer ID."""
|
||||
candidate = (peer or "user").strip()
|
||||
if not candidate:
|
||||
return session.user_peer_id
|
||||
|
||||
normalized = self._sanitize_id(candidate)
|
||||
if normalized == self._sanitize_id("user"):
|
||||
return session.user_peer_id
|
||||
if normalized == self._sanitize_id("ai"):
|
||||
return session.assistant_peer_id
|
||||
|
||||
return normalized
|
||||
|
||||
def _resolve_observer_target(
|
||||
self,
|
||||
session: HonchoSession,
|
||||
peer: str | None,
|
||||
) -> tuple[str, str | None]:
|
||||
"""Resolve observer and target peer IDs for context/search/profile queries."""
|
||||
target_peer_id = self._resolve_peer_id(session, peer)
|
||||
if target_peer_id is None:
|
||||
return session.user_peer_id, None
|
||||
|
||||
if target_peer_id == session.assistant_peer_id:
|
||||
return session.assistant_peer_id, session.assistant_peer_id
|
||||
|
||||
if self._ai_observe_others:
|
||||
return session.assistant_peer_id, target_peer_id
|
||||
|
||||
return target_peer_id, None
|
||||
|
||||
def get_peer_card(self, session_key: str, peer: str = "user") -> list[str]:
|
||||
"""
|
||||
Fetch a peer card — a curated list of key facts.
|
||||
|
||||
Fast, no LLM reasoning. Returns raw structured facts Honcho has
|
||||
inferred about the user (name, role, preferences, patterns).
|
||||
inferred about the target peer (name, role, preferences, patterns).
|
||||
Empty list if unavailable.
|
||||
"""
|
||||
session = self._cache.get(session_key)
|
||||
|
|
@ -924,12 +996,19 @@ class HonchoSessionManager:
|
|||
return []
|
||||
|
||||
try:
|
||||
return self._fetch_peer_card(session.user_peer_id)
|
||||
observer_peer_id, target_peer_id = self._resolve_observer_target(session, peer)
|
||||
return self._fetch_peer_card(observer_peer_id, target=target_peer_id)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to fetch peer card from Honcho: %s", e)
|
||||
return []
|
||||
|
||||
def search_context(self, session_key: str, query: str, max_tokens: int = 800) -> str:
|
||||
def search_context(
|
||||
self,
|
||||
session_key: str,
|
||||
query: str,
|
||||
max_tokens: int = 800,
|
||||
peer: str = "user",
|
||||
) -> str:
|
||||
"""
|
||||
Semantic search over Honcho session context.
|
||||
|
||||
|
|
@ -941,6 +1020,7 @@ class HonchoSessionManager:
|
|||
session_key: Session to search against.
|
||||
query: Search query for semantic matching.
|
||||
max_tokens: Token budget for returned content.
|
||||
peer: Peer alias or explicit peer ID to search about.
|
||||
|
||||
Returns:
|
||||
Relevant context excerpts as a string, or empty string if none.
|
||||
|
|
@ -950,7 +1030,13 @@ class HonchoSessionManager:
|
|||
return ""
|
||||
|
||||
try:
|
||||
ctx = self._fetch_peer_context(session.user_peer_id, search_query=query)
|
||||
observer_peer_id, target = self._resolve_observer_target(session, peer)
|
||||
|
||||
ctx = self._fetch_peer_context(
|
||||
observer_peer_id,
|
||||
search_query=query,
|
||||
target=target,
|
||||
)
|
||||
parts = []
|
||||
if ctx["representation"]:
|
||||
parts.append(ctx["representation"])
|
||||
|
|
@ -962,16 +1048,17 @@ class HonchoSessionManager:
|
|||
logger.debug("Honcho search_context failed: %s", e)
|
||||
return ""
|
||||
|
||||
def create_conclusion(self, session_key: str, content: str) -> bool:
|
||||
"""Write a conclusion about the user back to Honcho.
|
||||
def create_conclusion(self, session_key: str, content: str, peer: str = "user") -> bool:
|
||||
"""Write a conclusion about a target peer back to Honcho.
|
||||
|
||||
Conclusions are facts the AI peer observes about the user —
|
||||
preferences, corrections, clarifications, project context.
|
||||
They feed into the user's peer card and representation.
|
||||
Conclusions are facts a peer observes about another peer or itself —
|
||||
preferences, corrections, clarifications, and project context.
|
||||
They feed into the target peer's card and representation.
|
||||
|
||||
Args:
|
||||
session_key: Session to associate the conclusion with.
|
||||
content: The conclusion text (e.g. "User prefers dark mode").
|
||||
content: The conclusion text.
|
||||
peer: Peer alias or explicit peer ID. "user" is the default alias.
|
||||
|
||||
Returns:
|
||||
True on success, False on failure.
|
||||
|
|
@ -985,25 +1072,87 @@ class HonchoSessionManager:
|
|||
return False
|
||||
|
||||
try:
|
||||
if self._ai_observe_others:
|
||||
# AI peer creates conclusion about user (cross-observation)
|
||||
target_peer_id = self._resolve_peer_id(session, peer)
|
||||
if target_peer_id is None:
|
||||
logger.warning("Could not resolve conclusion peer '%s' for session '%s'", peer, session_key)
|
||||
return False
|
||||
|
||||
if target_peer_id == session.assistant_peer_id:
|
||||
assistant_peer = self._get_or_create_peer(session.assistant_peer_id)
|
||||
conclusions_scope = assistant_peer.conclusions_of(session.user_peer_id)
|
||||
conclusions_scope = assistant_peer.conclusions_of(session.assistant_peer_id)
|
||||
elif self._ai_observe_others:
|
||||
assistant_peer = self._get_or_create_peer(session.assistant_peer_id)
|
||||
conclusions_scope = assistant_peer.conclusions_of(target_peer_id)
|
||||
else:
|
||||
# AI can't observe others — user peer creates self-conclusion
|
||||
user_peer = self._get_or_create_peer(session.user_peer_id)
|
||||
conclusions_scope = user_peer.conclusions_of(session.user_peer_id)
|
||||
target_peer = self._get_or_create_peer(target_peer_id)
|
||||
conclusions_scope = target_peer.conclusions_of(target_peer_id)
|
||||
|
||||
conclusions_scope.create([{
|
||||
"content": content.strip(),
|
||||
"session_id": session.honcho_session_id,
|
||||
}])
|
||||
logger.info("Created conclusion for %s: %s", session_key, content[:80])
|
||||
logger.info("Created conclusion about %s for %s: %s", target_peer_id, session_key, content[:80])
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to create conclusion: %s", e)
|
||||
return False
|
||||
|
||||
def delete_conclusion(self, session_key: str, conclusion_id: str, peer: str = "user") -> bool:
|
||||
"""Delete a conclusion by ID. Use only for PII removal.
|
||||
|
||||
Args:
|
||||
session_key: Session key for peer resolution.
|
||||
conclusion_id: The conclusion ID to delete.
|
||||
peer: Peer alias or explicit peer ID.
|
||||
|
||||
Returns:
|
||||
True on success, False on failure.
|
||||
"""
|
||||
session = self._cache.get(session_key)
|
||||
if not session:
|
||||
return False
|
||||
try:
|
||||
target_peer_id = self._resolve_peer_id(session, peer)
|
||||
if target_peer_id == session.assistant_peer_id:
|
||||
observer = self._get_or_create_peer(session.assistant_peer_id)
|
||||
scope = observer.conclusions_of(session.assistant_peer_id)
|
||||
elif self._ai_observe_others:
|
||||
observer = self._get_or_create_peer(session.assistant_peer_id)
|
||||
scope = observer.conclusions_of(target_peer_id)
|
||||
else:
|
||||
target_peer = self._get_or_create_peer(target_peer_id)
|
||||
scope = target_peer.conclusions_of(target_peer_id)
|
||||
scope.delete(conclusion_id)
|
||||
logger.info("Deleted conclusion %s for %s", conclusion_id, session_key)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to delete conclusion %s: %s", conclusion_id, e)
|
||||
return False
|
||||
|
||||
def set_peer_card(self, session_key: str, card: list[str], peer: str = "user") -> list[str] | None:
|
||||
"""Update a peer's card.
|
||||
|
||||
Args:
|
||||
session_key: Session key for peer resolution.
|
||||
card: New peer card as list of fact strings.
|
||||
peer: Peer alias or explicit peer ID.
|
||||
|
||||
Returns:
|
||||
Updated card on success, None on failure.
|
||||
"""
|
||||
session = self._cache.get(session_key)
|
||||
if not session:
|
||||
return None
|
||||
try:
|
||||
peer_id = self._resolve_peer_id(session, peer)
|
||||
peer_obj = self._get_or_create_peer(peer_id)
|
||||
result = peer_obj.set_card(card)
|
||||
logger.info("Updated peer card for %s (%d facts)", peer_id, len(card))
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error("Failed to set peer card: %s", e)
|
||||
return None
|
||||
|
||||
def seed_ai_identity(self, session_key: str, content: str, source: str = "manual") -> bool:
|
||||
"""
|
||||
Seed the AI peer's Honcho representation from text content.
|
||||
|
|
@ -1061,7 +1210,7 @@ class HonchoSessionManager:
|
|||
return {"representation": "", "card": ""}
|
||||
|
||||
try:
|
||||
ctx = self._fetch_peer_context(session.assistant_peer_id)
|
||||
ctx = self._fetch_peer_context(session.assistant_peer_id, target=session.assistant_peer_id)
|
||||
return {
|
||||
"representation": ctx["representation"] or "",
|
||||
"card": "\n".join(ctx["card"]),
|
||||
|
|
|
|||
|
|
@ -593,6 +593,7 @@ class AIAgent:
|
|||
prefill_messages: List[Dict[str, Any]] = None,
|
||||
platform: str = None,
|
||||
user_id: str = None,
|
||||
gateway_session_key: str = None,
|
||||
skip_context_files: bool = False,
|
||||
skip_memory: bool = False,
|
||||
session_db=None,
|
||||
|
|
@ -658,6 +659,7 @@ class AIAgent:
|
|||
self.ephemeral_system_prompt = ephemeral_system_prompt
|
||||
self.platform = platform # "cli", "telegram", "discord", "whatsapp", etc.
|
||||
self._user_id = user_id # Platform user identifier (gateway sessions)
|
||||
self._gateway_session_key = gateway_session_key # Stable per-chat key (e.g. agent:main:telegram:dm:123)
|
||||
# Pluggable print function — CLI replaces this with _cprint so that
|
||||
# raw ANSI status lines are routed through prompt_toolkit's renderer
|
||||
# instead of going directly to stdout where patch_stdout's StdoutProxy
|
||||
|
|
@ -1206,6 +1208,9 @@ class AIAgent:
|
|||
# Thread gateway user identity for per-user memory scoping
|
||||
if self._user_id:
|
||||
_init_kwargs["user_id"] = self._user_id
|
||||
# Thread gateway session key for stable per-chat Honcho session isolation
|
||||
if self._gateway_session_key:
|
||||
_init_kwargs["gateway_session_key"] = self._gateway_session_key
|
||||
# Profile identity for per-profile provider scoping
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
|
|
|
|||
56
tests/honcho_plugin/test_cli.py
Normal file
56
tests/honcho_plugin/test_cli.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Tests for plugins/memory/honcho/cli.py."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
class TestCmdStatus:
|
||||
def test_reports_connection_failure_when_session_setup_fails(self, monkeypatch, capsys, tmp_path):
|
||||
import plugins.memory.honcho.cli as honcho_cli
|
||||
|
||||
cfg_path = tmp_path / "honcho.json"
|
||||
cfg_path.write_text("{}")
|
||||
|
||||
class FakeConfig:
|
||||
enabled = True
|
||||
api_key = "root-key"
|
||||
workspace_id = "hermes"
|
||||
host = "hermes"
|
||||
base_url = None
|
||||
ai_peer = "hermes"
|
||||
peer_name = "eri"
|
||||
recall_mode = "hybrid"
|
||||
user_observe_me = True
|
||||
user_observe_others = False
|
||||
ai_observe_me = False
|
||||
ai_observe_others = True
|
||||
write_frequency = "async"
|
||||
session_strategy = "per-session"
|
||||
context_tokens = 800
|
||||
|
||||
def resolve_session_name(self):
|
||||
return "hermes"
|
||||
|
||||
monkeypatch.setattr(honcho_cli, "_read_config", lambda: {"apiKey": "***"})
|
||||
monkeypatch.setattr(honcho_cli, "_config_path", lambda: cfg_path)
|
||||
monkeypatch.setattr(honcho_cli, "_local_config_path", lambda: cfg_path)
|
||||
monkeypatch.setattr(honcho_cli, "_active_profile_name", lambda: "default")
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
|
||||
lambda host=None: FakeConfig(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"plugins.memory.honcho.client.get_honcho_client",
|
||||
lambda cfg: object(),
|
||||
)
|
||||
|
||||
def _boom(hcfg, client):
|
||||
raise RuntimeError("Invalid API key")
|
||||
|
||||
monkeypatch.setattr(honcho_cli, "_show_peer_cards", _boom)
|
||||
monkeypatch.setitem(__import__("sys").modules, "honcho", SimpleNamespace())
|
||||
|
||||
honcho_cli.cmd_status(SimpleNamespace(all=False))
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "FAILED (Invalid API key)" in out
|
||||
assert "Connection... OK" not in out
|
||||
|
|
@ -25,6 +25,7 @@ class TestHonchoClientConfigDefaults:
|
|||
assert config.workspace_id == "hermes"
|
||||
assert config.api_key is None
|
||||
assert config.environment == "production"
|
||||
assert config.timeout is None
|
||||
assert config.enabled is False
|
||||
assert config.save_messages is True
|
||||
assert config.session_strategy == "per-directory"
|
||||
|
|
@ -76,6 +77,11 @@ class TestFromEnv:
|
|||
assert config.base_url == "http://localhost:8000"
|
||||
assert config.enabled is True
|
||||
|
||||
def test_reads_timeout_from_env(self):
|
||||
with patch.dict(os.environ, {"HONCHO_TIMEOUT": "90"}, clear=True):
|
||||
config = HonchoClientConfig.from_env()
|
||||
assert config.timeout == 90.0
|
||||
|
||||
|
||||
class TestFromGlobalConfig:
|
||||
def test_missing_config_falls_back_to_env(self, tmp_path):
|
||||
|
|
@ -87,10 +93,10 @@ class TestFromGlobalConfig:
|
|||
assert config.enabled is False
|
||||
assert config.api_key is None
|
||||
|
||||
def test_reads_full_config(self, tmp_path):
|
||||
def test_reads_full_config(self, tmp_path, monkeypatch):
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({
|
||||
"apiKey": "my-honcho-key",
|
||||
"apiKey": "***",
|
||||
"workspace": "my-workspace",
|
||||
"environment": "staging",
|
||||
"peerName": "alice",
|
||||
|
|
@ -108,9 +114,11 @@ class TestFromGlobalConfig:
|
|||
}
|
||||
}
|
||||
}))
|
||||
# Isolate from real ~/.hermes/honcho.json
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "isolated"))
|
||||
|
||||
config = HonchoClientConfig.from_global_config(config_path=config_file)
|
||||
assert config.api_key == "my-honcho-key"
|
||||
assert config.api_key == "***"
|
||||
# Host block workspace overrides root workspace
|
||||
assert config.workspace_id == "override-ws"
|
||||
assert config.ai_peer == "override-ai"
|
||||
|
|
@ -154,10 +162,31 @@ class TestFromGlobalConfig:
|
|||
def test_session_strategy_default_from_global_config(self, tmp_path):
|
||||
"""from_global_config with no sessionStrategy should match dataclass default."""
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({"apiKey": "key"}))
|
||||
config_file.write_text(json.dumps({"apiKey": "***"}))
|
||||
config = HonchoClientConfig.from_global_config(config_path=config_file)
|
||||
assert config.session_strategy == "per-directory"
|
||||
|
||||
def test_context_tokens_default_is_none(self, tmp_path):
|
||||
"""Default context_tokens should be None (uncapped) unless explicitly set."""
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({"apiKey": "***"}))
|
||||
config = HonchoClientConfig.from_global_config(config_path=config_file)
|
||||
assert config.context_tokens is None
|
||||
|
||||
def test_context_tokens_explicit_sets_cap(self, tmp_path):
|
||||
"""Explicit contextTokens in config sets the cap."""
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({"apiKey": "***", "contextTokens": 1200}))
|
||||
config = HonchoClientConfig.from_global_config(config_path=config_file)
|
||||
assert config.context_tokens == 1200
|
||||
|
||||
def test_context_tokens_explicit_overrides_default(self, tmp_path):
|
||||
"""Explicit contextTokens in config should override the default."""
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({"apiKey": "***", "contextTokens": 2000}))
|
||||
config = HonchoClientConfig.from_global_config(config_path=config_file)
|
||||
assert config.context_tokens == 2000
|
||||
|
||||
def test_context_tokens_host_block_wins(self, tmp_path):
|
||||
"""Host block contextTokens should override root."""
|
||||
config_file = tmp_path / "config.json"
|
||||
|
|
@ -232,6 +261,20 @@ class TestFromGlobalConfig:
|
|||
config = HonchoClientConfig.from_global_config(config_path=config_file)
|
||||
assert config.base_url == "http://root:9000"
|
||||
|
||||
def test_timeout_from_config_root(self, tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({"timeout": 75}))
|
||||
|
||||
config = HonchoClientConfig.from_global_config(config_path=config_file)
|
||||
assert config.timeout == 75.0
|
||||
|
||||
def test_request_timeout_alias_from_config_root(self, tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({"requestTimeout": "82.5"}))
|
||||
|
||||
config = HonchoClientConfig.from_global_config(config_path=config_file)
|
||||
assert config.timeout == 82.5
|
||||
|
||||
|
||||
class TestResolveSessionName:
|
||||
def test_manual_override(self):
|
||||
|
|
@ -333,13 +376,14 @@ class TestResolveConfigPath:
|
|||
hermes_home.mkdir()
|
||||
local_cfg = hermes_home / "honcho.json"
|
||||
local_cfg.write_text(json.dumps({
|
||||
"apiKey": "local-key",
|
||||
"apiKey": "***",
|
||||
"workspace": "local-ws",
|
||||
}))
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), \
|
||||
patch.object(Path, "home", return_value=tmp_path):
|
||||
config = HonchoClientConfig.from_global_config()
|
||||
assert config.api_key == "local-key"
|
||||
assert config.api_key == "***"
|
||||
assert config.workspace_id == "local-ws"
|
||||
|
||||
|
||||
|
|
@ -500,46 +544,103 @@ class TestObservationModeMigration:
|
|||
assert cfg.ai_observe_others is True
|
||||
|
||||
|
||||
class TestInitOnSessionStart:
|
||||
"""Tests for the initOnSessionStart config field."""
|
||||
class TestGetHonchoClient:
|
||||
def teardown_method(self):
|
||||
reset_honcho_client()
|
||||
|
||||
def test_default_is_false(self):
|
||||
def test_passes_timeout_from_config(self):
|
||||
fake_honcho = MagicMock(name="Honcho")
|
||||
cfg = HonchoClientConfig(
|
||||
api_key="test-key",
|
||||
timeout=91.0,
|
||||
workspace_id="hermes",
|
||||
environment="production",
|
||||
)
|
||||
|
||||
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho:
|
||||
client = get_honcho_client(cfg)
|
||||
|
||||
assert client is fake_honcho
|
||||
mock_honcho.assert_called_once()
|
||||
assert mock_honcho.call_args.kwargs["timeout"] == 91.0
|
||||
|
||||
def test_hermes_config_timeout_override_used_when_config_timeout_missing(self):
|
||||
fake_honcho = MagicMock(name="Honcho")
|
||||
cfg = HonchoClientConfig(
|
||||
api_key="test-key",
|
||||
workspace_id="hermes",
|
||||
environment="production",
|
||||
)
|
||||
|
||||
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \
|
||||
patch("hermes_cli.config.load_config", return_value={"honcho": {"timeout": 88}}):
|
||||
client = get_honcho_client(cfg)
|
||||
|
||||
assert client is fake_honcho
|
||||
mock_honcho.assert_called_once()
|
||||
assert mock_honcho.call_args.kwargs["timeout"] == 88.0
|
||||
|
||||
def test_hermes_request_timeout_alias_used(self):
|
||||
fake_honcho = MagicMock(name="Honcho")
|
||||
cfg = HonchoClientConfig(
|
||||
api_key="test-key",
|
||||
workspace_id="hermes",
|
||||
environment="production",
|
||||
)
|
||||
|
||||
with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \
|
||||
patch("hermes_cli.config.load_config", return_value={"honcho": {"request_timeout": "77.5"}}):
|
||||
client = get_honcho_client(cfg)
|
||||
|
||||
assert client is fake_honcho
|
||||
mock_honcho.assert_called_once()
|
||||
assert mock_honcho.call_args.kwargs["timeout"] == 77.5
|
||||
|
||||
|
||||
class TestResolveSessionNameGatewayKey:
|
||||
"""Regression tests for gateway_session_key priority in resolve_session_name.
|
||||
|
||||
Ensures gateway platforms get stable per-chat Honcho sessions even when
|
||||
sessionStrategy=per-session would otherwise create ephemeral sessions.
|
||||
Regression: plugin refactor 924bc67e dropped gateway key plumbing.
|
||||
"""
|
||||
|
||||
def test_gateway_key_overrides_per_session_strategy(self):
|
||||
"""gateway_session_key must win over per-session session_id."""
|
||||
config = HonchoClientConfig(session_strategy="per-session")
|
||||
result = config.resolve_session_name(
|
||||
session_id="20260412_171002_69bb38",
|
||||
gateway_session_key="agent:main:telegram:dm:8439114563",
|
||||
)
|
||||
assert result == "agent-main-telegram-dm-8439114563"
|
||||
|
||||
def test_session_title_still_wins_over_gateway_key(self):
|
||||
"""Explicit /title remap takes priority over gateway_session_key."""
|
||||
config = HonchoClientConfig(session_strategy="per-session")
|
||||
result = config.resolve_session_name(
|
||||
session_title="my-custom-title",
|
||||
session_id="20260412_171002_69bb38",
|
||||
gateway_session_key="agent:main:telegram:dm:8439114563",
|
||||
)
|
||||
assert result == "my-custom-title"
|
||||
|
||||
def test_per_session_fallback_without_gateway_key(self):
|
||||
"""Without gateway_session_key, per-session returns session_id (CLI path)."""
|
||||
config = HonchoClientConfig(session_strategy="per-session")
|
||||
result = config.resolve_session_name(
|
||||
session_id="20260412_171002_69bb38",
|
||||
gateway_session_key=None,
|
||||
)
|
||||
assert result == "20260412_171002_69bb38"
|
||||
|
||||
def test_gateway_key_sanitizes_special_chars(self):
|
||||
"""Colons and other non-alphanumeric chars are replaced with hyphens."""
|
||||
config = HonchoClientConfig()
|
||||
assert config.init_on_session_start is False
|
||||
|
||||
def test_root_level_true(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({
|
||||
"apiKey": "k",
|
||||
"initOnSessionStart": True,
|
||||
}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.init_on_session_start is True
|
||||
|
||||
def test_host_block_overrides_root(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({
|
||||
"apiKey": "k",
|
||||
"initOnSessionStart": True,
|
||||
"hosts": {"hermes": {"initOnSessionStart": False}},
|
||||
}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.init_on_session_start is False
|
||||
|
||||
def test_host_block_true_overrides_root_absent(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({
|
||||
"apiKey": "k",
|
||||
"hosts": {"hermes": {"initOnSessionStart": True}},
|
||||
}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.init_on_session_start is True
|
||||
|
||||
def test_absent_everywhere_defaults_false(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({"apiKey": "k"}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.init_on_session_start is False
|
||||
result = config.resolve_session_name(
|
||||
gateway_session_key="agent:main:telegram:dm:8439114563",
|
||||
)
|
||||
assert result == "agent-main-telegram-dm-8439114563"
|
||||
assert ":" not in result
|
||||
|
||||
|
||||
class TestResetHonchoClient:
|
||||
|
|
|
|||
|
|
@ -205,27 +205,62 @@ class TestPeerLookupHelpers:
|
|||
|
||||
def test_get_peer_card_uses_direct_peer_lookup(self):
|
||||
mgr, session = self._make_cached_manager()
|
||||
user_peer = MagicMock()
|
||||
user_peer.get_card.return_value = ["Name: Robert"]
|
||||
mgr._get_or_create_peer = MagicMock(return_value=user_peer)
|
||||
assistant_peer = MagicMock()
|
||||
assistant_peer.get_card.return_value = ["Name: Robert"]
|
||||
mgr._get_or_create_peer = MagicMock(return_value=assistant_peer)
|
||||
|
||||
assert mgr.get_peer_card(session.key) == ["Name: Robert"]
|
||||
user_peer.get_card.assert_called_once_with()
|
||||
assistant_peer.get_card.assert_called_once_with(target=session.user_peer_id)
|
||||
|
||||
def test_search_context_uses_peer_context_response(self):
|
||||
def test_search_context_uses_assistant_perspective_with_target(self):
|
||||
mgr, session = self._make_cached_manager()
|
||||
user_peer = MagicMock()
|
||||
user_peer.context.return_value = SimpleNamespace(
|
||||
assistant_peer = MagicMock()
|
||||
assistant_peer.context.return_value = SimpleNamespace(
|
||||
representation="Robert runs neuralancer",
|
||||
peer_card=["Location: Melbourne"],
|
||||
)
|
||||
mgr._get_or_create_peer = MagicMock(return_value=user_peer)
|
||||
mgr._get_or_create_peer = MagicMock(return_value=assistant_peer)
|
||||
|
||||
result = mgr.search_context(session.key, "neuralancer")
|
||||
|
||||
assert "Robert runs neuralancer" in result
|
||||
assert "- Location: Melbourne" in result
|
||||
user_peer.context.assert_called_once_with(search_query="neuralancer")
|
||||
assistant_peer.context.assert_called_once_with(
|
||||
target=session.user_peer_id,
|
||||
search_query="neuralancer",
|
||||
)
|
||||
|
||||
def test_search_context_unified_mode_uses_user_self_context(self):
|
||||
mgr, session = self._make_cached_manager()
|
||||
mgr._ai_observe_others = False
|
||||
user_peer = MagicMock()
|
||||
user_peer.context.return_value = SimpleNamespace(
|
||||
representation="Unified self context",
|
||||
peer_card=["Name: Robert"],
|
||||
)
|
||||
mgr._get_or_create_peer = MagicMock(return_value=user_peer)
|
||||
|
||||
result = mgr.search_context(session.key, "self")
|
||||
|
||||
assert "Unified self context" in result
|
||||
user_peer.context.assert_called_once_with(search_query="self")
|
||||
|
||||
def test_search_context_accepts_explicit_ai_peer_id(self):
|
||||
mgr, session = self._make_cached_manager()
|
||||
ai_peer = MagicMock()
|
||||
ai_peer.context.return_value = SimpleNamespace(
|
||||
representation="Assistant self context",
|
||||
peer_card=["Role: Assistant"],
|
||||
)
|
||||
mgr._get_or_create_peer = MagicMock(return_value=ai_peer)
|
||||
|
||||
result = mgr.search_context(session.key, "assistant", peer=session.assistant_peer_id)
|
||||
|
||||
assert "Assistant self context" in result
|
||||
ai_peer.context.assert_called_once_with(
|
||||
target=session.assistant_peer_id,
|
||||
search_query="assistant",
|
||||
)
|
||||
|
||||
def test_get_prefetch_context_fetches_user_and_ai_from_peer_api(self):
|
||||
mgr, session = self._make_cached_manager()
|
||||
|
|
@ -235,9 +270,15 @@ class TestPeerLookupHelpers:
|
|||
peer_card=["Name: Robert"],
|
||||
)
|
||||
ai_peer = MagicMock()
|
||||
ai_peer.context.return_value = SimpleNamespace(
|
||||
representation="AI representation",
|
||||
peer_card=["Owner: Robert"],
|
||||
ai_peer.context.side_effect = lambda **kwargs: SimpleNamespace(
|
||||
representation=(
|
||||
"AI representation" if kwargs.get("target") == session.assistant_peer_id
|
||||
else "Mixed representation"
|
||||
),
|
||||
peer_card=(
|
||||
["Role: Assistant"] if kwargs.get("target") == session.assistant_peer_id
|
||||
else ["Name: Robert"]
|
||||
),
|
||||
)
|
||||
mgr._get_or_create_peer = MagicMock(side_effect=[user_peer, ai_peer])
|
||||
|
||||
|
|
@ -247,17 +288,23 @@ class TestPeerLookupHelpers:
|
|||
"representation": "User representation",
|
||||
"card": "Name: Robert",
|
||||
"ai_representation": "AI representation",
|
||||
"ai_card": "Owner: Robert",
|
||||
"ai_card": "Role: Assistant",
|
||||
}
|
||||
user_peer.context.assert_called_once_with()
|
||||
ai_peer.context.assert_called_once_with()
|
||||
user_peer.context.assert_called_once_with(target=session.user_peer_id)
|
||||
ai_peer.context.assert_called_once_with(target=session.assistant_peer_id)
|
||||
|
||||
def test_get_ai_representation_uses_peer_api(self):
|
||||
mgr, session = self._make_cached_manager()
|
||||
ai_peer = MagicMock()
|
||||
ai_peer.context.return_value = SimpleNamespace(
|
||||
representation="AI representation",
|
||||
peer_card=["Owner: Robert"],
|
||||
ai_peer.context.side_effect = lambda **kwargs: SimpleNamespace(
|
||||
representation=(
|
||||
"AI representation" if kwargs.get("target") == session.assistant_peer_id
|
||||
else "Mixed representation"
|
||||
),
|
||||
peer_card=(
|
||||
["Role: Assistant"] if kwargs.get("target") == session.assistant_peer_id
|
||||
else ["Name: Robert"]
|
||||
),
|
||||
)
|
||||
mgr._get_or_create_peer = MagicMock(return_value=ai_peer)
|
||||
|
||||
|
|
@ -265,9 +312,152 @@ class TestPeerLookupHelpers:
|
|||
|
||||
assert result == {
|
||||
"representation": "AI representation",
|
||||
"card": "Owner: Robert",
|
||||
"card": "Role: Assistant",
|
||||
}
|
||||
ai_peer.context.assert_called_once_with()
|
||||
ai_peer.context.assert_called_once_with(target=session.assistant_peer_id)
|
||||
|
||||
def test_create_conclusion_defaults_to_user_target(self):
|
||||
mgr, session = self._make_cached_manager()
|
||||
assistant_peer = MagicMock()
|
||||
scope = MagicMock()
|
||||
assistant_peer.conclusions_of.return_value = scope
|
||||
mgr._get_or_create_peer = MagicMock(return_value=assistant_peer)
|
||||
|
||||
ok = mgr.create_conclusion(session.key, "User prefers dark mode")
|
||||
|
||||
assert ok is True
|
||||
assistant_peer.conclusions_of.assert_called_once_with(session.user_peer_id)
|
||||
scope.create.assert_called_once_with([{
|
||||
"content": "User prefers dark mode",
|
||||
"session_id": session.honcho_session_id,
|
||||
}])
|
||||
|
||||
def test_create_conclusion_can_target_ai_peer(self):
|
||||
mgr, session = self._make_cached_manager()
|
||||
assistant_peer = MagicMock()
|
||||
scope = MagicMock()
|
||||
assistant_peer.conclusions_of.return_value = scope
|
||||
mgr._get_or_create_peer = MagicMock(return_value=assistant_peer)
|
||||
|
||||
ok = mgr.create_conclusion(session.key, "Assistant prefers terse summaries", peer="ai")
|
||||
|
||||
assert ok is True
|
||||
assistant_peer.conclusions_of.assert_called_once_with(session.assistant_peer_id)
|
||||
scope.create.assert_called_once_with([{
|
||||
"content": "Assistant prefers terse summaries",
|
||||
"session_id": session.honcho_session_id,
|
||||
}])
|
||||
|
||||
def test_create_conclusion_accepts_explicit_user_peer_id(self):
|
||||
mgr, session = self._make_cached_manager()
|
||||
assistant_peer = MagicMock()
|
||||
scope = MagicMock()
|
||||
assistant_peer.conclusions_of.return_value = scope
|
||||
mgr._get_or_create_peer = MagicMock(return_value=assistant_peer)
|
||||
|
||||
ok = mgr.create_conclusion(session.key, "Robert prefers vinyl", peer=session.user_peer_id)
|
||||
|
||||
assert ok is True
|
||||
assistant_peer.conclusions_of.assert_called_once_with(session.user_peer_id)
|
||||
scope.create.assert_called_once_with([{
|
||||
"content": "Robert prefers vinyl",
|
||||
"session_id": session.honcho_session_id,
|
||||
}])
|
||||
|
||||
|
||||
class TestConcludeToolDispatch:
|
||||
def test_honcho_conclude_defaults_to_user_peer(self):
|
||||
provider = HonchoMemoryProvider()
|
||||
provider._session_initialized = True
|
||||
provider._session_key = "telegram:123"
|
||||
provider._manager = MagicMock()
|
||||
provider._manager.create_conclusion.return_value = True
|
||||
|
||||
result = provider.handle_tool_call(
|
||||
"honcho_conclude",
|
||||
{"conclusion": "User prefers dark mode"},
|
||||
)
|
||||
|
||||
assert "Conclusion saved for user" in result
|
||||
provider._manager.create_conclusion.assert_called_once_with(
|
||||
"telegram:123",
|
||||
"User prefers dark mode",
|
||||
peer="user",
|
||||
)
|
||||
|
||||
def test_honcho_conclude_can_target_ai_peer(self):
|
||||
provider = HonchoMemoryProvider()
|
||||
provider._session_initialized = True
|
||||
provider._session_key = "telegram:123"
|
||||
provider._manager = MagicMock()
|
||||
provider._manager.create_conclusion.return_value = True
|
||||
|
||||
result = provider.handle_tool_call(
|
||||
"honcho_conclude",
|
||||
{"conclusion": "Assistant likes terse replies", "peer": "ai"},
|
||||
)
|
||||
|
||||
assert "Conclusion saved for ai" in result
|
||||
provider._manager.create_conclusion.assert_called_once_with(
|
||||
"telegram:123",
|
||||
"Assistant likes terse replies",
|
||||
peer="ai",
|
||||
)
|
||||
|
||||
def test_honcho_profile_can_target_explicit_peer_id(self):
|
||||
provider = HonchoMemoryProvider()
|
||||
provider._session_initialized = True
|
||||
provider._session_key = "telegram:123"
|
||||
provider._manager = MagicMock()
|
||||
provider._manager.get_peer_card.return_value = ["Role: Assistant"]
|
||||
|
||||
result = provider.handle_tool_call(
|
||||
"honcho_profile",
|
||||
{"peer": "hermes"},
|
||||
)
|
||||
|
||||
assert "Role: Assistant" in result
|
||||
provider._manager.get_peer_card.assert_called_once_with("telegram:123", peer="hermes")
|
||||
|
||||
def test_honcho_search_can_target_explicit_peer_id(self):
|
||||
provider = HonchoMemoryProvider()
|
||||
provider._session_initialized = True
|
||||
provider._session_key = "telegram:123"
|
||||
provider._manager = MagicMock()
|
||||
provider._manager.search_context.return_value = "Assistant self context"
|
||||
|
||||
result = provider.handle_tool_call(
|
||||
"honcho_search",
|
||||
{"query": "assistant", "peer": "hermes"},
|
||||
)
|
||||
|
||||
assert "Assistant self context" in result
|
||||
provider._manager.search_context.assert_called_once_with(
|
||||
"telegram:123",
|
||||
"assistant",
|
||||
max_tokens=800,
|
||||
peer="hermes",
|
||||
)
|
||||
|
||||
def test_honcho_reasoning_can_target_explicit_peer_id(self):
|
||||
provider = HonchoMemoryProvider()
|
||||
provider._session_initialized = True
|
||||
provider._session_key = "telegram:123"
|
||||
provider._manager = MagicMock()
|
||||
provider._manager.dialectic_query.return_value = "Assistant answer"
|
||||
|
||||
result = provider.handle_tool_call(
|
||||
"honcho_reasoning",
|
||||
{"query": "who are you", "peer": "hermes"},
|
||||
)
|
||||
|
||||
assert "Assistant answer" in result
|
||||
provider._manager.dialectic_query.assert_called_once_with(
|
||||
"telegram:123",
|
||||
"who are you",
|
||||
reasoning_level=None,
|
||||
peer="hermes",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -366,6 +556,54 @@ class TestToolsModeInitBehavior:
|
|||
assert cfg.peer_name == "8439114563"
|
||||
|
||||
|
||||
class TestPerSessionMigrateGuard:
|
||||
"""Verify migrate_memory_files is skipped under per-session strategy.
|
||||
|
||||
per-session creates a fresh Honcho session every Hermes run. Uploading
|
||||
MEMORY.md/USER.md/SOUL.md to each short-lived session floods the backend
|
||||
with duplicate content. The guard was added to prevent orphan sessions
|
||||
containing only <prior_memory_file> wrappers.
|
||||
"""
|
||||
|
||||
def _make_provider_with_strategy(self, strategy, init_on_session_start=True):
|
||||
"""Create a HonchoMemoryProvider and track migrate_memory_files calls."""
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
cfg = HonchoClientConfig(
|
||||
api_key="test-key",
|
||||
enabled=True,
|
||||
recall_mode="tools",
|
||||
init_on_session_start=init_on_session_start,
|
||||
session_strategy=strategy,
|
||||
)
|
||||
|
||||
provider = HonchoMemoryProvider()
|
||||
|
||||
mock_manager = MagicMock()
|
||||
mock_session = MagicMock()
|
||||
mock_session.messages = [] # empty = new session → triggers migration path
|
||||
mock_manager.get_or_create.return_value = mock_session
|
||||
|
||||
with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \
|
||||
patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \
|
||||
patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \
|
||||
patch("hermes_constants.get_hermes_home", return_value=MagicMock()):
|
||||
provider.initialize(session_id="test-session-001")
|
||||
|
||||
return provider, mock_manager
|
||||
|
||||
def test_migrate_skipped_for_per_session(self):
|
||||
"""per-session strategy must NOT call migrate_memory_files."""
|
||||
_, mock_manager = self._make_provider_with_strategy("per-session")
|
||||
mock_manager.migrate_memory_files.assert_not_called()
|
||||
|
||||
def test_migrate_runs_for_per_directory(self):
|
||||
"""per-directory strategy with empty session SHOULD call migrate_memory_files."""
|
||||
_, mock_manager = self._make_provider_with_strategy("per-directory")
|
||||
mock_manager.migrate_memory_files.assert_called_once()
|
||||
|
||||
|
||||
class TestChunkMessage:
|
||||
def test_short_message_single_chunk(self):
|
||||
result = HonchoMemoryProvider._chunk_message("hello world", 100)
|
||||
|
|
@ -420,6 +658,60 @@ class TestChunkMessage:
|
|||
assert len(chunk) <= 25000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context token budget enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTruncateToBudget:
|
||||
def test_truncates_oversized_context(self):
|
||||
"""Text exceeding context_tokens budget is truncated at a word boundary."""
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
|
||||
provider = HonchoMemoryProvider()
|
||||
provider._config = HonchoClientConfig(context_tokens=10)
|
||||
|
||||
long_text = "word " * 200 # ~1000 chars, well over 10*4=40 char budget
|
||||
result = provider._truncate_to_budget(long_text)
|
||||
|
||||
assert len(result) <= 50 # budget_chars + ellipsis + word boundary slack
|
||||
assert result.endswith(" …")
|
||||
|
||||
def test_no_truncation_within_budget(self):
|
||||
"""Text within budget passes through unchanged."""
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
|
||||
provider = HonchoMemoryProvider()
|
||||
provider._config = HonchoClientConfig(context_tokens=1000)
|
||||
|
||||
short_text = "Name: Robert, Location: Melbourne"
|
||||
assert provider._truncate_to_budget(short_text) == short_text
|
||||
|
||||
def test_no_truncation_when_context_tokens_none(self):
|
||||
"""When context_tokens is None (explicit opt-out), no truncation."""
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
|
||||
provider = HonchoMemoryProvider()
|
||||
provider._config = HonchoClientConfig(context_tokens=None)
|
||||
|
||||
long_text = "word " * 500
|
||||
assert provider._truncate_to_budget(long_text) == long_text
|
||||
|
||||
def test_context_tokens_cap_bounds_prefetch(self):
|
||||
"""With an explicit token budget, oversized prefetch is bounded."""
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
|
||||
provider = HonchoMemoryProvider()
|
||||
provider._config = HonchoClientConfig(context_tokens=1200)
|
||||
|
||||
# Simulate a massive representation (10k chars)
|
||||
huge_text = "x" * 10000
|
||||
result = provider._truncate_to_budget(huge_text)
|
||||
|
||||
# 1200 tokens * 4 chars = 4800 chars + " …"
|
||||
assert len(result) <= 4805
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dialectic input guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -452,3 +744,44 @@ class TestDialecticInputGuard:
|
|||
# The query passed to chat() should be truncated
|
||||
actual_query = mock_peer.chat.call_args[0][0]
|
||||
assert len(actual_query) <= 100
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDialecticCadenceDefaults:
|
||||
"""Regression tests for dialectic_cadence default value."""
|
||||
|
||||
@staticmethod
|
||||
def _make_provider(cfg_extra=None):
|
||||
"""Create a HonchoMemoryProvider with mocked dependencies."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
|
||||
defaults = dict(api_key="test-key", enabled=True, recall_mode="hybrid")
|
||||
if cfg_extra:
|
||||
defaults.update(cfg_extra)
|
||||
cfg = HonchoClientConfig(**defaults)
|
||||
provider = HonchoMemoryProvider()
|
||||
mock_manager = MagicMock()
|
||||
mock_session = MagicMock()
|
||||
mock_session.messages = []
|
||||
mock_manager.get_or_create.return_value = mock_session
|
||||
|
||||
with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \
|
||||
patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \
|
||||
patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \
|
||||
patch("hermes_constants.get_hermes_home", return_value=MagicMock()):
|
||||
provider.initialize(session_id="test-session-001")
|
||||
|
||||
return provider
|
||||
|
||||
def test_default_is_3(self):
|
||||
"""Default dialectic_cadence should be 3 to avoid per-turn LLM calls."""
|
||||
provider = self._make_provider()
|
||||
assert provider._dialectic_cadence == 3
|
||||
|
||||
def test_config_override(self):
|
||||
"""dialecticCadence from config overrides the default."""
|
||||
provider = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 5}})
|
||||
assert provider._dialectic_cadence == 5
|
||||
|
|
|
|||
|
|
@ -44,9 +44,6 @@ hermes [global-options] <command> [subcommand/options]
|
|||
| `hermes webhook` | Manage dynamic webhook subscriptions for event-driven activation. |
|
||||
| `hermes doctor` | Diagnose config and dependency issues. |
|
||||
| `hermes dump` | Copy-pasteable setup summary for support/debugging. |
|
||||
| `hermes debug` | Debug tools — upload logs and system info for support. |
|
||||
| `hermes backup` | Back up Hermes home directory to a zip file. |
|
||||
| `hermes import` | Restore a Hermes backup from a zip file. |
|
||||
| `hermes logs` | View, tail, and filter agent/gateway/error log files. |
|
||||
| `hermes config` | Show, edit, migrate, and query configuration files. |
|
||||
| `hermes pairing` | Approve or revoke messaging pairing codes. |
|
||||
|
|
@ -60,10 +57,6 @@ hermes [global-options] <command> [subcommand/options]
|
|||
| `hermes sessions` | Browse, export, prune, rename, and delete sessions. |
|
||||
| `hermes insights` | Show token/cost/activity analytics. |
|
||||
| `hermes claw` | OpenClaw migration helpers. |
|
||||
| `hermes dashboard` | Launch the web dashboard for managing config, API keys, and sessions. |
|
||||
| `hermes debug` | Debug tools — upload logs and system info for support. |
|
||||
| `hermes backup` | Back up Hermes home directory to a zip file. |
|
||||
| `hermes import` | Restore a Hermes backup from a zip file. |
|
||||
| `hermes profile` | Manage profiles — multiple isolated Hermes instances. |
|
||||
| `hermes completion` | Print shell completion scripts (bash/zsh). |
|
||||
| `hermes version` | Show version information. |
|
||||
|
|
@ -83,11 +76,10 @@ Common options:
|
|||
| `-q`, `--query "..."` | One-shot, non-interactive prompt. |
|
||||
| `-m`, `--model <model>` | Override the model for this run. |
|
||||
| `-t`, `--toolsets <csv>` | Enable a comma-separated set of toolsets. |
|
||||
| `--provider <provider>` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `gemini`, `huggingface`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`, `kilocode`, `xiaomi`, `arcee`. |
|
||||
| `--provider <provider>` | Force a provider: `auto`, `openrouter`, `nous`, `openai-codex`, `copilot-acp`, `copilot`, `anthropic`, `huggingface`, `zai`, `kimi-coding`, `minimax`, `minimax-cn`, `deepseek`, `ai-gateway`, `opencode-zen`, `opencode-go`, `kilocode`, `alibaba`. |
|
||||
| `-s`, `--skills <name>` | Preload one or more skills for the session (can be repeated or comma-separated). |
|
||||
| `-v`, `--verbose` | Verbose output. |
|
||||
| `-Q`, `--quiet` | Programmatic mode: suppress banner/spinner/tool previews. |
|
||||
| `--image <path>` | Attach a local image to a single query. |
|
||||
| `--resume <session>` / `--continue [name]` | Resume a session directly from `chat`. |
|
||||
| `--worktree` | Create an isolated git worktree for this run. |
|
||||
| `--checkpoints` | Enable filesystem checkpoints before destructive file changes. |
|
||||
|
|
@ -148,23 +140,19 @@ Subcommands:
|
|||
|
||||
| Subcommand | Description |
|
||||
|------------|-------------|
|
||||
| `run` | Run the gateway in the foreground. Recommended for WSL, Docker, and Termux. |
|
||||
| `start` | Start the installed systemd/launchd background service. |
|
||||
| `stop` | Stop the service (or foreground process). |
|
||||
| `run` | Run the gateway in the foreground. |
|
||||
| `start` | Start the installed gateway service. |
|
||||
| `stop` | Stop the service. |
|
||||
| `restart` | Restart the service. |
|
||||
| `status` | Show service status. |
|
||||
| `install` | Install as a systemd (Linux) or launchd (macOS) background service. |
|
||||
| `install` | Install as a user service (`systemd` on Linux, `launchd` on macOS). |
|
||||
| `uninstall` | Remove the installed service. |
|
||||
| `setup` | Interactive messaging-platform setup. |
|
||||
|
||||
:::tip WSL users
|
||||
Use `hermes gateway run` instead of `hermes gateway start` — WSL's systemd support is unreliable. Wrap it in tmux for persistence: `tmux new -s hermes 'hermes gateway run'`. See [WSL FAQ](/docs/reference/faq#wsl-gateway-keeps-disconnecting-or-hermes-gateway-start-fails) for details.
|
||||
:::
|
||||
|
||||
## `hermes setup`
|
||||
|
||||
```bash
|
||||
hermes setup [model|tts|terminal|gateway|tools|agent] [--non-interactive] [--reset]
|
||||
hermes setup [model|terminal|gateway|tools|agent] [--non-interactive] [--reset]
|
||||
```
|
||||
|
||||
Use the full wizard or jump into one section:
|
||||
|
|
@ -362,70 +350,6 @@ config_overrides:
|
|||
`hermes dump` is specifically designed for sharing. For interactive diagnostics, use `hermes doctor`. For a visual overview, use `hermes status`.
|
||||
:::
|
||||
|
||||
## `hermes debug`
|
||||
|
||||
```bash
|
||||
hermes debug share [options]
|
||||
```
|
||||
|
||||
Upload a debug report (system info + recent logs) to a paste service and get a shareable URL. Useful for quick support requests — includes everything a helper needs to diagnose your issue.
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--lines <N>` | Number of log lines to include per log file (default: 200). |
|
||||
| `--expire <days>` | Paste expiry in days (default: 7). |
|
||||
| `--local` | Print the report locally instead of uploading. |
|
||||
|
||||
The report includes system info (OS, Python version, Hermes version), recent agent and gateway logs (512 KB limit per file), and redacted API key status. Keys are always redacted — no secrets are uploaded.
|
||||
|
||||
Paste services tried in order: paste.rs, dpaste.com.
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
hermes debug share # Upload debug report, print URL
|
||||
hermes debug share --lines 500 # Include more log lines
|
||||
hermes debug share --expire 30 # Keep paste for 30 days
|
||||
hermes debug share --local # Print report to terminal (no upload)
|
||||
```
|
||||
|
||||
## `hermes backup`
|
||||
|
||||
```bash
|
||||
hermes backup [options]
|
||||
```
|
||||
|
||||
Create a zip archive of your Hermes configuration, skills, sessions, and data. The backup excludes the hermes-agent codebase itself.
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-o`, `--output <path>` | Output path for the zip file (default: `~/hermes-backup-<timestamp>.zip`). |
|
||||
| `-q`, `--quick` | Quick snapshot: only critical state files (config.yaml, state.db, .env, auth, cron jobs). Much faster than a full backup. |
|
||||
| `-l`, `--label <name>` | Label for the snapshot (only used with `--quick`). |
|
||||
|
||||
The backup uses SQLite's `backup()` API for safe copying, so it works correctly even when Hermes is running (WAL-mode safe).
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
hermes backup # Full backup to ~/hermes-backup-*.zip
|
||||
hermes backup -o /tmp/hermes.zip # Full backup to specific path
|
||||
hermes backup --quick # Quick state-only snapshot
|
||||
hermes backup --quick --label "pre-upgrade" # Quick snapshot with label
|
||||
```
|
||||
|
||||
## `hermes import`
|
||||
|
||||
```bash
|
||||
hermes import <zipfile> [options]
|
||||
```
|
||||
|
||||
Restore a previously created Hermes backup into your Hermes home directory.
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-f`, `--force` | Overwrite existing files without confirmation. |
|
||||
|
||||
## `hermes logs`
|
||||
|
||||
```bash
|
||||
|
|
@ -452,7 +376,6 @@ View, tail, and filter Hermes log files. All logs are stored in `~/.hermes/logs/
|
|||
| `--level <LEVEL>` | Minimum log level to show: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`. |
|
||||
| `--session <ID>` | Filter lines containing a session ID substring. |
|
||||
| `--since <TIME>` | Show lines from a relative time ago: `30m`, `1h`, `2d`, etc. Supports `s` (seconds), `m` (minutes), `h` (hours), `d` (days). |
|
||||
| `--component <NAME>` | Filter by component: `gateway`, `agent`, `tools`, `cli`, `cron`. |
|
||||
|
||||
### Examples
|
||||
|
||||
|
|
@ -592,6 +515,7 @@ Subcommands:
|
|||
| `map [name]` | Map the current directory to a Honcho session name. Omit `name` to list current mappings. |
|
||||
| `peer` | Show or update peer names and dialectic reasoning level. Options: `--user NAME`, `--ai NAME`, `--reasoning LEVEL`. |
|
||||
| `mode [mode]` | Show or set recall mode: `hybrid`, `context`, or `tools`. Omit to show current. |
|
||||
| `strategy [name]` | Show or set session strategy: `per-session`, `per-directory`, `per-repo`, `global`. Omit to show current. |
|
||||
| `tokens` | Show or set token budgets for context and dialectic. Options: `--context N`, `--dialectic N`. |
|
||||
| `identity [file] [--show]` | Seed or show the AI peer identity representation. |
|
||||
| `enable` | Enable Honcho for the active profile. |
|
||||
|
|
@ -733,7 +657,7 @@ hermes insights [--days N] [--source platform]
|
|||
hermes claw migrate [options]
|
||||
```
|
||||
|
||||
Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom path) and writes to `~/.hermes`. Automatically detects legacy directory names (`~/.clawdbot`, `~/.moltbot`) and config filenames (`clawdbot.json`, `moltbot.json`).
|
||||
Migrate your OpenClaw setup to Hermes. Reads from `~/.openclaw` (or a custom path) and writes to `~/.hermes`. Automatically detects legacy directory names (`~/.clawdbot`, `~/.moldbot`) and config filenames (`clawdbot.json`, `moldbot.json`).
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
|
|
@ -774,28 +698,6 @@ hermes claw migrate --preset user-data --overwrite
|
|||
hermes claw migrate --source /home/user/old-openclaw
|
||||
```
|
||||
|
||||
## `hermes dashboard`
|
||||
|
||||
```bash
|
||||
hermes dashboard [options]
|
||||
```
|
||||
|
||||
Launch the web dashboard — a browser-based UI for managing configuration, API keys, and monitoring sessions. Requires `pip install hermes-agent[web]` (FastAPI + Uvicorn). See [Web Dashboard](/docs/user-guide/features/web-dashboard) for full documentation.
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `--port` | `9119` | Port to run the web server on |
|
||||
| `--host` | `127.0.0.1` | Bind address |
|
||||
| `--no-open` | — | Don't auto-open the browser |
|
||||
|
||||
```bash
|
||||
# Default — opens browser to http://127.0.0.1:9119
|
||||
hermes dashboard
|
||||
|
||||
# Custom port, no browser
|
||||
hermes dashboard --port 8080 --no-open
|
||||
```
|
||||
|
||||
## `hermes profile`
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ In addition to built-in tools, Hermes can load tools dynamically from MCP server
|
|||
| `ha_list_services` | List available Home Assistant services (actions) for device control. Shows what actions can be performed on each device type and what parameters they accept. Use this to discover how to control devices found via ha_list_entities. | — |
|
||||
|
||||
:::note
|
||||
**Honcho tools** (`honcho_conclude`, `honcho_context`, `honcho_profile`, `honcho_search`) are no longer built-in. They are available via the Honcho memory provider plugin at `plugins/memory/honcho/`. See [Plugins](../user-guide/features/plugins.md) for installation and usage.
|
||||
**Honcho tools** (`honcho_profile`, `honcho_search`, `honcho_context`, `honcho_reasoning`, `honcho_conclude`) are available via the Honcho memory provider plugin at `plugins/memory/honcho/`. `honcho_context` returns full session context including summary, representation, card, and recent messages. `honcho_reasoning` provides LLM-synthesized answers. See [Memory Providers](../user-guide/features/memory-providers.md) for installation and usage.
|
||||
:::
|
||||
|
||||
## `image_gen` toolset
|
||||
|
|
|
|||
|
|
@ -49,33 +49,70 @@ Get an API key at [honcho.dev](https://honcho.dev).
|
|||
|
||||
## Configuration Options
|
||||
|
||||
```yaml
|
||||
# ~/.hermes/config.yaml
|
||||
honcho:
|
||||
observation: directional # "unified" (default for new installs) or "directional"
|
||||
peer_name: "" # auto-detected from platform, or set manually
|
||||
```
|
||||
Honcho is configured in `~/.honcho/config.json` (global) or `$HERMES_HOME/honcho.json` (profile-local). The setup wizard handles this for you.
|
||||
|
||||
**Observation modes:**
|
||||
- `unified` — All observations go into a single pool. Simpler, good for single-agent setups.
|
||||
- `directional` — Observations are tagged with direction (user→agent, agent→user). Enables richer analysis of conversation dynamics.
|
||||
**Key settings:**
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| `sessionStrategy` | `per-directory` | `per-directory`, `per-repo`, `per-session`, or `global` |
|
||||
| `recallMode` | `hybrid` | `hybrid` (auto-inject + tools), `context` (inject only), `tools` (tools only) |
|
||||
| `contextTokens` | uncapped | Token budget for auto-injected context per turn. Set to an integer (e.g. 1200) to cap |
|
||||
| `dialecticReasoningLevel` | `low` | Base reasoning level: `minimal`, `low`, `medium`, `high`, `max` |
|
||||
| `dialecticDynamic` | `true` | When `true`, model can override reasoning level per-call via tool param |
|
||||
| `dialecticCadence` | `3` | Turns between Honcho LLM calls (higher = fewer calls) |
|
||||
| `writeFrequency` | `async` | When to flush messages to Honcho: `async` (background thread), `turn` (sync each turn), `session` (flush on end), or integer N (every N turns) |
|
||||
| `observation` | all on | Per-peer `observeMe`/`observeOthers` booleans |
|
||||
|
||||
**Session strategy** controls how Honcho sessions map to your work:
|
||||
- `per-session` — each `hermes` run gets a fresh session. Clean starts, memory via tools. Recommended for new users.
|
||||
- `per-directory` — one Honcho session per working directory. Context accumulates across runs.
|
||||
- `per-repo` — one session per git repository.
|
||||
- `global` — single session across all directories.
|
||||
|
||||
**Recall mode** controls how memory flows into conversations:
|
||||
- `hybrid` — context auto-injected into system prompt AND tools available (model decides when to query).
|
||||
- `context` — auto-injection only, tools hidden.
|
||||
- `tools` — tools only, no auto-injection. Agent must explicitly call `honcho_reasoning`, `honcho_search`, etc.
|
||||
|
||||
**Dialectic cadence** controls cost. With default `3`, Honcho rebuilds the user model every 3 turns instead of every turn — ~66% fewer LLM calls without losing model fidelity.
|
||||
|
||||
**Settings per recall mode:**
|
||||
|
||||
| Setting | `hybrid` | `context` | `tools` |
|
||||
|---------|----------|-----------|---------|
|
||||
| `writeFrequency` | flushes messages | flushes messages | flushes messages |
|
||||
| `dialecticCadence` | gates auto LLM calls | gates auto LLM calls | irrelevant — model calls explicitly |
|
||||
| `contextTokens` | caps injection | caps injection | irrelevant — no injection |
|
||||
| `dialecticDynamic` | gates model override | N/A (no tools) | gates model override |
|
||||
|
||||
In `tools` mode, the model is fully in control — it calls `honcho_reasoning` when it wants, at whatever `reasoning_level` it picks. `dialecticCadence` and `contextTokens` only apply to modes with auto-injection (`hybrid` and `context`).
|
||||
|
||||
## Tools
|
||||
|
||||
When Honcho is active as the memory provider, four additional tools become available:
|
||||
When Honcho is active as the memory provider, five tools become available:
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `honcho_conclude` | Trigger server-side dialectic reasoning on recent conversations |
|
||||
| `honcho_context` | Retrieve relevant context from Honcho's memory for the current conversation |
|
||||
| `honcho_profile` | View or update the user's Honcho profile |
|
||||
| `honcho_search` | Semantic search across all stored conclusions and observations |
|
||||
| `honcho_profile` | Read or update peer card — pass `card` (list of facts) to update, omit to read |
|
||||
| `honcho_search` | Semantic search over context — raw excerpts, no LLM synthesis |
|
||||
| `honcho_context` | Full session context — summary, representation, card, recent messages |
|
||||
| `honcho_reasoning` | Synthesized answer from Honcho's LLM — pass `reasoning_level` (minimal/low/medium/high/max) to control depth |
|
||||
| `honcho_conclude` | Create or delete conclusions — pass `conclusion` to create, `delete_id` to remove (PII only) |
|
||||
|
||||
## CLI Commands
|
||||
|
||||
```bash
|
||||
hermes honcho status # Show connection status and config
|
||||
hermes honcho status # Connection status, config, and key settings
|
||||
hermes honcho setup # Interactive setup wizard
|
||||
hermes honcho strategy # Show or set session strategy
|
||||
hermes honcho peer # Update peer names for multi-agent setups
|
||||
hermes honcho mode # Show or set recall mode
|
||||
hermes honcho tokens # Show or set context token budget
|
||||
hermes honcho identity # Show Honcho peer identity
|
||||
hermes honcho sync # Sync host blocks for all profiles
|
||||
hermes honcho enable # Enable Honcho
|
||||
hermes honcho disable # Disable Honcho
|
||||
```
|
||||
|
||||
## Migrating from `hermes honcho`
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ AI-native cross-session user modeling with dialectic Q&A, semantic search, and p
|
|||
| **Data storage** | Honcho Cloud or self-hosted |
|
||||
| **Cost** | Honcho pricing (cloud) / free (self-hosted) |
|
||||
|
||||
**Tools:** `honcho_profile` (peer card), `honcho_search` (semantic search), `honcho_context` (LLM-synthesized), `honcho_conclude` (store facts)
|
||||
**Tools:** `honcho_profile` (read/update peer card), `honcho_search` (semantic search), `honcho_context` (session context — summary, representation, card, messages), `honcho_reasoning` (LLM-synthesized), `honcho_conclude` (create/delete conclusions)
|
||||
|
||||
**Setup Wizard:**
|
||||
```bash
|
||||
|
|
@ -74,10 +74,12 @@ hermes memory setup # select "honcho"
|
|||
| `workspace` | host key | Shared workspace ID |
|
||||
| `recallMode` | `hybrid` | `hybrid` (auto-inject + tools), `context` (inject only), `tools` (tools only) |
|
||||
| `observation` | all on | Per-peer `observeMe`/`observeOthers` booleans |
|
||||
| `writeFrequency` | `async` | `async`, `turn`, `session`, or integer N |
|
||||
| `writeFrequency` | `async` | When to flush messages to Honcho: `async` (background thread), `turn` (sync each turn), `session` (flush on end), or integer N (every N turns) |
|
||||
| `sessionStrategy` | `per-directory` | `per-directory`, `per-repo`, `per-session`, `global` |
|
||||
| `contextTokens` | uncapped | Token budget for auto-injected context per turn. Set to an integer (e.g. 1200) to cap |
|
||||
| `dialecticReasoningLevel` | `low` | `minimal`, `low`, `medium`, `high`, `max` |
|
||||
| `dialecticDynamic` | `true` | Auto-bump reasoning by query length |
|
||||
| `dialecticDynamic` | `true` | When `true`, model can override reasoning level per-call via tool param |
|
||||
| `dialecticCadence` | `3` | Turns between Honcho LLM calls — only applies to `hybrid`/`context` modes. In `tools` mode the model calls `honcho_reasoning` explicitly |
|
||||
| `messageMaxChars` | `25000` | Max chars per message (chunked if exceeded) |
|
||||
|
||||
</details>
|
||||
|
|
@ -165,6 +167,7 @@ This inherits settings from the default `hermes` host block and creates new AI p
|
|||
},
|
||||
"dialecticReasoningLevel": "low",
|
||||
"dialecticDynamic": true,
|
||||
"dialecticCadence": 3,
|
||||
"dialecticMaxChars": 600,
|
||||
"messageMaxChars": 25000,
|
||||
"saveMessages": true
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue