Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
015742c
fix(honcho): make honcho_search do real cross-session message search
k4z4n0v4 Jun 25, 2026
afec784
fix(honcho): honest, non-overlapping tool descriptions + drop dead param
k4z4n0v4 Jun 25, 2026
03ca76f
fix(honcho): inject base context on the first message of a session
k4z4n0v4 Jun 25, 2026
83dca7f
fix(honcho): stop dropping dialectic results on trivial turns
k4z4n0v4 Jun 25, 2026
109e50e
fix(honcho): honor per-host timeout in config resolution
k4z4n0v4 Jun 25, 2026
189f958
fix(honcho): resolve cost-awareness config from host block
k4z4n0v4 Jun 25, 2026
e7457f5
fix(honcho): don't let first-turn injection suppress dialectic
k4z4n0v4 Jun 25, 2026
f0aff80
fix(honcho): ground dialectic queries in latest user message
ljy-2000 Jul 10, 2026
b39e44a
feat(honcho): add list mode to honcho_conclude so delete can resolve …
vizi0uz Jul 6, 2026
f521c69
fix(honcho): preserve delayed and rewritten recall context
erosika Jul 10, 2026
cbf31aa
fix(honcho): update SDK and restore CI coverage
erosika Jul 10, 2026
fa69c7d
fix(honcho): stop clipping honcho_reasoning tool results to the injec…
vizi0uz Jul 6, 2026
302e1c2
fix(honcho): gate the stalled-init prefetch wait to the first turn
erosika Jul 13, 2026
b95230d
refactor(memory): make query rewrite provider-agnostic
erosika Jul 16, 2026
36cd1da
feat(honcho): make latency-adding paths configurable
erosika Jul 16, 2026
14de895
docs(honcho): document latency flags and updated tool contracts
erosika Jul 16, 2026
ee9cb92
fix(honcho): enforce recall latency and budget contracts
erosika Jul 16, 2026
f572be2
fix(memory): fail fast on stuck external prefetch
LeonSGP43 Jul 12, 2026
181d8a9
fix(memory): align external prefetch guard with fail-open contracts
erosika Jul 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 62 additions & 2 deletions agent/memory_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
# teardown indefinitely — the worker threads are daemon, so anything still
# running past this window dies with the interpreter.
_SYNC_DRAIN_TIMEOUT_S = 5.0
_EXTERNAL_PREFETCH_TIMEOUT_S = 8.0


def normalize_tool_schema(schema: Any) -> Optional[Dict[str, Any]]:
Expand Down Expand Up @@ -357,10 +358,19 @@ class MemoryManager:
provider is allowed. Failures in one provider never block the other.
"""

def __init__(self) -> None:
def __init__(self, *, external_prefetch_timeout: Optional[float] = None) -> None:
self._providers: List[MemoryProvider] = []
self._tool_to_provider: Dict[str, MemoryProvider] = {}
self._has_external: bool = False # True once a non-builtin provider is added
self._external_prefetch_timeout = (
_EXTERNAL_PREFETCH_TIMEOUT_S
if external_prefetch_timeout is None
else float(external_prefetch_timeout)
)
if self._external_prefetch_timeout <= 0:
raise ValueError("external_prefetch_timeout must be positive")
self._external_prefetch_threads: Dict[str, threading.Thread] = {}
self._external_prefetch_lock = threading.Lock()
# Background executor for end-of-turn sync/prefetch. Lazily created on
# first use so the common builtin-only path spawns no extra threads.
# A single worker serializes a provider's writes (turn N must land
Expand Down Expand Up @@ -504,7 +514,7 @@ def prefetch_all(self, query: str, *, session_id: str = "") -> str:
parts = []
for provider in self._providers:
try:
result = provider.prefetch(clean_query, session_id=session_id)
result = self._prefetch_provider(provider, clean_query, session_id=session_id)
if result and result.strip():
parts.append(result)
except Exception as e:
Expand All @@ -514,6 +524,56 @@ def prefetch_all(self, query: str, *, session_id: str = "") -> str:
)
return "\n\n".join(parts)

def _prefetch_provider(
self, provider: MemoryProvider, query: str, *, session_id: str = ""
) -> str:
if provider.name == "builtin":
return provider.prefetch(query, session_id=session_id)

result_box: Dict[str, str] = {}
error_box: Dict[str, Exception] = {}

def _run() -> None:
try:
result_box["value"] = provider.prefetch(query, session_id=session_id) or ""
except Exception as exc: # pragma: no cover - re-raised by caller
error_box["value"] = exc

thread = threading.Thread(
target=_run,
daemon=True,
name=f"memory-prefetch-{provider.name}",
)
with self._external_prefetch_lock:
existing = self._external_prefetch_threads.get(provider.name)
if existing is not None:
if existing.is_alive():
logger.debug(
"Memory provider '%s' prefetch is still running; skipping this turn",
provider.name,
)
return ""
self._external_prefetch_threads.pop(provider.name, None)
self._external_prefetch_threads[provider.name] = thread
thread.start()

thread.join(self._external_prefetch_timeout)
if thread.is_alive():
logger.warning(
"Memory provider '%s' prefetch timed out after %.1fs; skipping it until "
"the stuck call returns",
provider.name,
self._external_prefetch_timeout,
)
return ""

with self._external_prefetch_lock:
if self._external_prefetch_threads.get(provider.name) is thread:
self._external_prefetch_threads.pop(provider.name, None)
if error_box:
raise error_box["value"]
return result_box.get("value", "")

def queue_prefetch_all(self, query: str, *, session_id: str = "") -> None:
"""Queue background prefetch on all providers for the next turn.

Expand Down
8 changes: 8 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1636,6 +1636,14 @@ def _ensure_hermes_home_managed(home: Path):
"extra_body": {},
"language": "",
},
"memory_query_rewrite": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 8,
"extra_body": {},
},
"tts_audio_tags": {
"provider": "auto",
"model": "",
Expand Down
1 change: 1 addition & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3251,6 +3251,7 @@ def _clear_stale_openai_base_url():
("approval", "Approval", "smart command approval"),
("mcp", "MCP", "MCP tool reasoning"),
("title_generation", "Title generation", "session titles"),
("memory_query_rewrite", "Memory query rewrite", "memory retrieval queries"),
("tts_audio_tags", "TTS audio tags", "Gemini TTS tag insertion"),
("skills_hub", "Skills hub", "skills search/install"),
("triage_specifier", "Triage specifier", "kanban spec fleshing"),
Expand Down
33 changes: 28 additions & 5 deletions plugins/memory/honcho/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,29 @@ Multi-pass `.chat()` reasoning about the user, appended after base context.

Both layers are joined, then truncated to fit `contextTokens` budget via `_truncate_to_budget` (tokens × 4 chars, word-boundary safe).

### Latest-Message Query Rewrite (opt-in)

When `queryRewrite: true`, dialectic pass 0 first uses the shared
`memory_query_rewrite` auxiliary task to turn the latest message into one
concise memory-retrieval question. The rewritten question is used for the
dialectic request; base-context retrieval still uses the raw message as its
search query. If rewriting times out or returns an invalid result, the plugin
falls back to the existing cold/warm prompt below. With the flag on, the
generic dialectic prewarm is skipped so it cannot shadow the first user
message.

**Off by default** — the rewrite adds one auxiliary-model call per dialectic
cycle (not per pass). Select a fast, inexpensive model under `hermes model`
-> auxiliary models -> **Memory query rewrite**; its request timeout is
`auxiliary.memory_query_rewrite.timeout` in config.yaml (default 8s). The
task and module (`plugins/memory/query_rewrite.py`) are provider-agnostic —
any memory provider can reuse them. `dialecticCadence` still controls how
often the cycle runs.

### Cold Start vs Warm Session Prompts

Dialectic pass 0 automatically selects its prompt based on session state:
When latest-message rewriting is unavailable, dialectic pass 0 automatically
selects its fallback prompt based on session state:

- **Cold** (no base context cached): "Who is this person? What are their preferences, goals, and working style? Focus on facts that would help an AI assistant be immediately useful."
- **Warm** (base context exists): "Given what's been discussed in this session so far, what context about this user is most relevant to the current conversation? Prioritize active context over biographical facts."
Expand Down Expand Up @@ -106,10 +126,10 @@ Five bidirectional tools. All accept an optional `peer` parameter (`"user"` or `
| Tool | LLM call? | Description |
|------|-----------|-------------|
| `honcho_profile` | No | Peer card — key facts snapshot |
| `honcho_search` | No | Semantic search over stored context (800 tok default, 2000 max) |
| `honcho_search` | No | Cross-session message search (hybrid semantic + keyword, ranked excerpts; 800 tok default, 2000 max) |
| `honcho_context` | No | Full session context: summary, representation, card, messages |
| `honcho_reasoning` | Yes | LLM-synthesized answer via dialectic `.chat()` |
| `honcho_conclude` | No | Write a persistent fact/conclusion about the user |
| `honcho_conclude` | No | Write, list/search, or delete persistent conclusions (list surfaces the ids delete needs) |

Tool visibility depends on `recallMode`: hidden in `context` mode, always present in `tools` and `hybrid`.

Expand Down Expand Up @@ -264,7 +284,7 @@ Host key is derived from the active Hermes profile: `hermes` (default) or `herme
| `dialecticDepthLevels` | array | — | Optional array of reasoning level strings per pass. Overrides proportional defaults. Example: `["minimal", "low", "medium"]` |
| `dialecticReasoningLevel` | string | `"low"` | Base reasoning level for `.chat()`: `"minimal"`, `"low"`, `"medium"`, `"high"`, `"max"` |
| `dialecticDynamic` | bool | `true` | When `true`, model can override reasoning level per-call via `honcho_reasoning` tool. When `false`, always uses `dialecticReasoningLevel` |
| `dialecticMaxChars` | int | `600` | Max chars of dialectic result injected into system prompt |
| `dialecticMaxChars` | int | `600` | Max chars of the auto-injected dialectic supplement. Applies only to auto-injection — explicit `honcho_reasoning` tool results return in full |
| `dialecticMaxInputChars` | int | `10000` | Max chars for dialectic query input to `.chat()`. Honcho cloud limit: 10k |
| `reasoningHeuristic` | bool | `true` | Query-adaptive: auto-scale the auto-injected dialectic's level up by query length (+1 at ≥120 chars, +2 at ≥400), clamped at `reasoningLevelCap`. `false` pins every auto call to `dialecticReasoningLevel` |
| `reasoningLevelCap` | string | `"high"` | Ceiling for `reasoningHeuristic` scaling: `"minimal"`, `"low"`, `"medium"`, `"high"`, `"max"` |
Expand All @@ -282,7 +302,10 @@ Host key is derived from the active Hermes profile: `hermes` (default) or `herme
|-----|------|---------|-------------|
| `contextCadence` | int | `1` | Minimum turns between base context refreshes (session summary + representation + card) |
| `dialecticCadence` | int | `1` | Minimum turns between dialectic `.chat()` firings |
| `injectionFrequency` | string | `"every-turn"` | `"every-turn"` or `"first-turn"` (inject context on the first user message only, skip from turn 2 onward) |
| `injectionFrequency` | string | `"every-turn"` | `"every-turn"` or `"first-turn"` (inject base context on the first user message only; the dialectic supplement keeps its own cadence) |
| `queryRewrite` | bool | `false` | Rewrite the latest message into a retrieval query before dialectic (one extra auxiliary LLM call per cycle) |
| `firstTurnBaseWait` | float | `3.0` | Max seconds turn 1 waits for base context / session init. `0` disables the wait (fully async; context surfaces on later turns). Turns 2+ never wait on a stalled init |
| `firstTurnDialecticWait` | float | `2.0` | Max seconds turn 1 waits for a dialectic result. `0` disables |

### Observation (Granular)

Expand Down
Loading
Loading